ctfy.server.models
Public wire-model package — re-exports for back-compat.
1"""Public wire-model package — re-exports for back-compat.""" 2 3from __future__ import annotations 4 5from ctfy.server.models.achievement import ( 6 AchievementCatalogEntry, 7 AchievementCopy, 8 AchievementProgress, 9 AchievementSummary, 10 EasterEggClaim, 11 MyAchievementsResponse, 12 RecentUnlock, 13 StarGazerVerifyResponse, 14 TeamAchievement, 15) 16from ctfy.server.models.activity import ( 17 Activity, 18 ActivityTimeseries, 19 ActivityTimeseriesBucket, 20) 21from ctfy.server.models.admin import ( 22 AccountKeyRow, 23 AdminArtifactStorageFlag, 24 AdminChallengeLastError, 25 AdminChallengeLatencyBucket, 26 AdminChallengeStatsRow, 27 AdminChallengeTimeseries, 28 AdminChallengeTimeseriesBucket, 29 AdminHealthFlags, 30 AdminOverview, 31 AdminOverviewCounts, 32 AdminRecentError, 33 AdminSilentChallenge, 34 AdminSolveCell, 35 AdminSolveMatrix, 36 AdminSolveMatrixChallenge, 37 AdminSolveMatrixTeam, 38 AdminStuckInstance, 39 AdminTaskInfo, 40 AdminTaskListResponse, 41 AdminTaskLogLine, 42 AdminTaskLogsResponse, 43 AdminTaskSubmitRequest, 44 AdminTimeseries, 45 AdminTimeseriesBucket, 46 AdminTimeseriesMetric, 47 AdminTrafficInstanceRow, 48 AdminTrafficSummary, 49 AdminTrafficTeamRow, 50 AdminUnhealthyNode, 51 DeceptionStats, 52 DeploymentConfig, 53 LlmBudgetPage, 54 LlmBudgetResetInfo, 55 LlmBudgetResetRequest, 56 LlmBudgetRow, 57 LlmProviderCreate, 58 LlmProviderInfo, 59 LlmProviderPage, 60 LlmProviderProbe, 61 PlatformSettingInfo, 62 PlatformSettingPatch, 63 QuestionAttemptResetInfo, 64 QuestionAttemptResetRequest, 65 ScheduledJobInfo, 66 ScheduledJobPatch, 67 ShellRecordingInfo, 68) 69from ctfy.server.models.announcement import ( 70 AnnouncementCreate, 71 AnnouncementInfo, 72 AnnouncementUpdate, 73) 74from ctfy.server.models.auth import ( 75 AddEmailRequest, 76 AdminTokenRevokeResponse, 77 AuthTokenResponse, 78 CreateFineGrainedTokenRequest, 79 CreateFineGrainedTokenResponse, 80 DeviceApproveRequest, 81 DeviceCodeResponse, 82 DeviceInfoResponse, 83 DeviceTokenRequest, 84 DeviceTokenResponse, 85 EmailCodeAuthInfo, 86 EmailVerificationSent, 87 EmailVerificationStatus, 88 LinkedIdentity, 89 LinkStartResponse, 90 LoginRequest, 91 OAuthProviderInfo, 92 PasswordAuthInfo, 93 ProvidersResponse, 94 RegisterRequest, 95 ScopeCatalogEntry, 96 SetPasswordRequest, 97 TokenInfo, 98 TokenLifetimeRequest, 99 TokenScopesResponse, 100 TokenScopeUpdate, 101 TokenTemplatesResponse, 102 TokenTraceLink, 103 TokenTraceRequest, 104 TokenTraceResponse, 105 UserEmailInfo, 106 VerifyAddedEmailRequest, 107 VerifyEmailRequest, 108) 109from ctfy.server.models.authoring import ( 110 AdminAuthoredChallengeDetail, 111 AdminAuthoredChallengeRow, 112 AdminAuthoredRevisionRow, 113 AuditFinding, 114 AuthoredAuditReport, 115 AuthoredAutoSmoke, 116 AuthoredBuildCapability, 117 AuthoredChallengeFileBody, 118 AuthoredChallengeFileEntry, 119 AuthoredChallengeFileRequest, 120 AuthoredChallengeInfo, 121 AuthoredChallengeReviewRequest, 122 AuthoredChallengeRow, 123 AuthoredCurationElsewhere, 124 AuthoredCurationState, 125 AuthoredDeclarations, 126 AuthoredGraduationInfo, 127 AuthoredQualityFlag, 128 AuthoredReceptionRow, 129 AuthoredReceptionState, 130 AuthoredRevisionDiff, 131 AuthoredRevisionFileChange, 132 AuthoredRevisionInfo, 133 AuthoredRevisionRow, 134 AuthoringDivision, 135 AuthoringReceptionInfo, 136 AuthoringTemplateFile, 137 AuthoringTemplateManifest, 138 AuthorReputation, 139 ContributorRow, 140 CreditedChallengeRow, 141 ReactionCount, 142) 143from ctfy.server.models.awd import ( 144 MAX_FLAGS_PER_SUBMISSION, 145 AwdArenaGate, 146 AwdArenaGateLink, 147 AwdArenaLink, 148 AwdArenaPeerRow, 149 AwdArenaPeersResponse, 150 AwdAttackAnchorResponse, 151 AwdAttackAnchorRow, 152 AwdAttackMatrixResponse, 153 AwdAttackMatrixRow, 154 AwdBoardEntry, 155 AwdBoardResponse, 156 AwdCaptureCorroborationRow, 157 AwdCorroborationResponse, 158 AwdDefenceAnchorResponse, 159 AwdDefenceAnchorRow, 160 AwdDefenceTimelineCell, 161 AwdDefenceTimelineResponse, 162 AwdDefenceTimelineRow, 163 AwdEventRow, 164 AwdEventsResponse, 165 AwdFlagResult, 166 AwdFlagSubmitRequest, 167 AwdFlagSubmitResponse, 168 AwdForensicAnalysis, 169 AwdForensicsResponse, 170 AwdForensicsRow, 171 AwdMatchConfig, 172 AwdMatchInfo, 173 AwdMatchStatus, 174 AwdProvisionPlanInfo, 175 AwdRoundHistoryResponse, 176 AwdRoundPoint, 177 AwdRoundSeries, 178 AwdSlaHistoryResponse, 179 AwdSlaProbeRow, 180 AwdTarget, 181 AwdTargetsResponse, 182 AwdVpnPeer, 183 AwdVpnRequest, 184 AwdVpnStatus, 185) 186from ctfy.server.models.challenge import ( 187 AdminChallengeQuestions, 188 AdminPatchSubmissionDetail, 189 AdminPatchSubmissionInfo, 190 AdminPatchSubmissionPage, 191 AgentEntrantInfo, 192 AttachmentList, 193 BlueprintHostInfo, 194 BlueprintMilestoneInfo, 195 BuildPushScopeRequest, 196 ChallengeBlueprint, 197 ChallengeBuildKickoffNodeResult, 198 ChallengeBuildKickoffResponse, 199 ChallengeBuildNodeState, 200 ChallengeBuildStateResponse, 201 ChallengeBuildStateRow, 202 ChallengeFacetCount, 203 ChallengeFacets, 204 ChallengeFlagStats, 205 ChallengeInfo, 206 ChallengePullKickoffNodeResult, 207 ChallengePullKickoffResponse, 208 ChallengePullNodeState, 209 ChallengePullStateResponse, 210 ChallengePullStateRow, 211 ChallengeRescanNodeResult, 212 ChallengeRescanResult, 213 ChallengeSolveAttempt, 214 ChallengeSolveAttemptsResponse, 215 ChallengeStats, 216 ChallengeTeamSolveSummary, 217 ChallengeValuePoint, 218 CompetitionChallengeBreakdown, 219 CompetitionChallengeRow, 220 CompetitionScoreDistribution, 221 CompetitionScoreHistory, 222 DefenceInfo, 223 DefenceLiveFile, 224 DefenceMatrixResponse, 225 DefenceMatrixRow, 226 DefenceSource, 227 MilestoneProgress, 228 MySolveSummary, 229 PatchStepInfo, 230 PatchSubmissionCreate, 231 PatchSubmissionInfo, 232 QaChallengeView, 233 QaSubmissionCreate, 234 QuestionPublicInfo, 235 RangeProgress, 236 RangeProgressRow, 237 RangeReplay, 238 RangeReplayFrame, 239 RangeReplayPosition, 240 ScoreboardEntry, 241 ScoreBucket, 242 ScoreHistoryPoint, 243 ScoreHistorySeries, 244 SubmissionCreate, 245 SubmissionResponse, 246 UserScoreboardEntry, 247) 248from ctfy.server.models.changelog import ( 249 AdminChangelogEntryInfo, 250 ChangelogAnnounceRequest, 251 ChangelogAnnounceResponse, 252 ChangelogEntryCreate, 253 ChangelogEntryInfo, 254 ChangelogEntryUpdate, 255 ChangelogImportEntry, 256 ChangelogImportRequest, 257 ChangelogImportResponse, 258 ChangelogLatestResponse, 259 ChangelogListResponse, 260 ChangelogSeenResponse, 261) 262from ctfy.server.models.competition import ( 263 AdminCompetitionAttachmentsResponse, 264 AuthoringReviewerInfo, 265 AuthoringScaleInfo, 266 AwdClockBrief, 267 BoardCriterion, 268 BulkInviteRequest, 269 BulkInviteResponse, 270 BulkInviteResultRow, 271 CompetitionAdminInfo, 272 CompetitionApplicationInfo, 273 CompetitionApplicationRequest, 274 CompetitionApplicationReviewRequest, 275 CompetitionApplicationRow, 276 CompetitionAttachmentInfo, 277 CompetitionAttachmentsResponse, 278 CompetitionAttachmentUpdate, 279 CompetitionCreate, 280 CompetitionDetail, 281 CompetitionFormatPreset, 282 CompetitionInfo, 283 CompetitionInviteInfo, 284 CompetitionMembershipInfo, 285 CompetitionRegistrationInfo, 286 CompetitionSessionInfo, 287 CompetitionUpdate, 288 GrantAuthoringReviewerRequest, 289 GrantCompetitionAdminRequest, 290 VirtualGhostBoard, 291 VirtualGhostRow, 292) 293from ctfy.server.models.eval import ( 294 RUN_ARTIFACT_BLOCKED, 295 RUN_TRAFFIC_BLOCKED, 296 RUN_TRANSCRIPT_BLOCKED, 297 RUN_WALL_BLOCKED, 298 RUN_WRITEUP_BLOCKED, 299 TRANSCRIPT_REFUSALS, 300 WALL_REFUSALS, 301 AdminHarnessRow, 302 AgentActionRow, 303 AgentActionsPage, 304 AgentPulseBeat, 305 AgentPulseLane, 306 AgentPulsePage, 307 EvalAllowanceGrant, 308 EvalAllowanceRow, 309 EvalArtifactBucket, 310 EvalArtifactReport, 311 EvalEntryRequest, 312 EvalLeaderboard, 313 EvalRunCoverage, 314 EvalTrendPoint, 315 HarnessModelCell, 316 HarnessStanding, 317 HarnessTags, 318 HarnessUpdate, 319 LeaderboardCell, 320 LlmCallBody, 321 LlmCallPage, 322 LlmCallRow, 323 LlmModelOption, 324 LlmProviderChoice, 325 ModelStanding, 326 MyEvalRunPage, 327 MyEvalRunRow, 328 MyLlmKey, 329 MyLlmKeyCreate, 330 MyLlmKeyProbe, 331 PublicTranscriptBlock, 332 PublicTranscriptResponse, 333 PublicTranscriptTurn, 334 QuestionCoverageRow, 335 RegistryPushTarget, 336 RegistryQuotaInfo, 337 RegistryUsageReport, 338 RegistryUsageRow, 339 RunTranscriptRequest, 340 RunWallRequest, 341 RunWriteup, 342 VendorStandardSet, 343 VendorStanding, 344) 345from ctfy.server.models.feedback import ( 346 AdminFeedbackRow, 347 FeedbackStats, 348 MyReactionsResponse, 349) 350from ctfy.server.models.gauntlet import ( 351 GauntletBuildCost, 352 GauntletBuildStep, 353 GauntletLibraryResponse, 354 GauntletModuleInfo, 355 GauntletPlanRequest, 356 GauntletPlanResponse, 357 GauntletPreset, 358 GauntletPreviewRequest, 359 GauntletPreviewResponse, 360 GauntletRangeCreate, 361 GauntletRangeCreated, 362 GauntletRangeInfo, 363 GauntletRangeListResponse, 364 GauntletShortfall, 365) 366from ctfy.server.models.instance import ( 367 AnswerTraceHit, 368 AnswerTraceRequest, 369 AnswerTraceResponse, 370 ArchiveStoreReport, 371 CheckAnswerRequest, 372 CheckAnswerResponse, 373 InstanceAnswersResponse, 374 InstanceInfo, 375 InstanceQuestionInfo, 376 InstanceRecordArtifacts, 377 InstanceRecordDetail, 378 InstanceRecordInfo, 379 InstanceRecordInfoDetail, 380 InstanceStatusResponse, 381 InstanceStorageReport, 382 InstanceTrafficPage, 383 InstanceTrafficWitnesses, 384 NodeCaptureStoreRow, 385 PlayerShellCreate, 386 PlayerShellTicket, 387 RenewResponse, 388 RevealedAnswer, 389 SshCredential, 390 SshExchangeRequest, 391 StartRequest, 392 StartResponse, 393 StopResponse, 394 TrafficWitness, 395 VerifyAnswerRequest, 396 VerifyAnswerResponse, 397) 398from ctfy.server.models.institution import ( 399 AffiliationInfo, 400 AffiliationSelection, 401 InstitutionCreate, 402 InstitutionInfo, 403 InstitutionPage, 404 InstitutionSuggestion, 405 InstitutionSuggestionPage, 406) 407from ctfy.server.models.meta import ( 408 ErrorResponse, 409 HealthResponse, 410 MetaAgents, 411 MetaAuthoredArchive, 412 MetaChallenges, 413 MetaContribute, 414 MetaEvents, 415 MetaInstanceTtl, 416 MetaLlm, 417 MetaMine, 418 MetaOps, 419 MetaPlatform, 420 MetaResponse, 421) 422from ctfy.server.models.node import ( 423 ClusterInfo, 424 CreateInviteRequest, 425 CreateInviteResponse, 426 ImageConsistencyRow, 427 NodeAuthoredPush, 428 NodeAuthoredResult, 429 NodeAuthoredSmokePush, 430 NodeAuthoredSmokeResult, 431 NodeAuthoredSyncLine, 432 NodeCaptureStoreReport, 433 NodeHeartbeat, 434 NodeHeartbeatResponse, 435 NodeInfo, 436 NodeInviteInfo, 437 NodePatch, 438 NodeRangePush, 439 NodeRangeResult, 440 NodeRangeSyncLine, 441 NodeRangeVerifyPush, 442 NodeRangeVerifyResult, 443 NodeRegister, 444 NodeRegisterResponse, 445 NodeRegistryTicket, 446 NodeRegistryTicketRequest, 447) 448from ctfy.server.models.notification import ( 449 EmailSuppressionCreate, 450 EmailSuppressionInfo, 451 EmailSuppressionListResponse, 452 EmailTemplateInfo, 453 EmailTemplateListResponse, 454 NotificationCategoryInfo, 455 NotificationChannelCell, 456 NotificationPreferencesResponse, 457 NotificationPreferenceUpdate, 458 TestEmailRequest, 459 TestEmailResult, 460) 461from ctfy.server.models.pagination import ( 462 BigLimitOffsetPage, 463 BigLimitOffsetPageOf100, 464 LimitOffsetPageUpTo200, 465 LimitOffsetPageUpTo500, 466) 467from ctfy.server.models.records import ( 468 CompetitionRecords, 469 RecordAuthoredChallenge, 470 RecordEvalRun, 471 RecordSolve, 472 RecordSubmission, 473 RecordTeam, 474) 475from ctfy.server.models.registration import ( 476 MyRegistrationResponse, 477 RegistrationFieldSpec, 478 RegistrationFormSpec, 479 RegistrationRosterRow, 480 RegistrationSubmitRequest, 481 RegistrationSummary, 482 RegistrationTeamRow, 483 ReviewDecisionRequest, 484 TeamLogoUploadResponse, 485) 486from ctfy.server.models.registry import ( 487 RegistryGcStatus, 488 RegistryManifestRow, 489 RegistryReclaimItem, 490 RegistryReclaimPlan, 491 RegistryReclaimRequest, 492 RegistryRepo, 493 RegistryRepoList, 494 RegistryTagList, 495) 496from ctfy.server.models.report import ( 497 MyReportRow, 498 MyReportsResponse, 499 ReportAttachmentInfo, 500 ReportAttachmentsResponse, 501 ReportClaimRow, 502 ReportClaimsPage, 503 ReportGradeInfo, 504 ReportGradesResponse, 505 ReportGradeSummary, 506 ReportInfo, 507 ReportPipeline, 508 ReportPipelineEvent, 509 ReportRosterResponse, 510 ReportRosterRow, 511 ReportSaveRequest, 512 ReportSaveResponse, 513 ReportStanding, 514 ReportVersionsResponse, 515) 516from ctfy.server.models.series import ( 517 CompetitionSeriesConfig, 518 CompetitionSeriesInfo, 519 SeriesOccurrencePreview, 520 SeriesPreview, 521) 522from ctfy.server.models.team import ( 523 AdminTeamRow, 524 CompetitionRoster, 525 CompetitionRosterTeam, 526 InboxAnnouncement, 527 InboxCaptainRequest, 528 InboxCompetitionInvite, 529 InboxIncomingInvite, 530 InboxOutgoingRequest, 531 InboxResponse, 532 TeamAffiliationSelection, 533 TeamCaptainRequest, 534 TeamDetail, 535 TeamDisqualifyRequest, 536 TeamInfo, 537 TeamInviteInfo, 538 TeamMemberInfo, 539 TeamStandingInfo, 540) 541from ctfy.server.models.user import ( 542 PROFILE_VISIBILITY_KEYS, 543 AccountDeletionAccepted, 544 AdminUserInfo, 545 CalendarBucket, 546 DeleteMeRequest, 547 DifficultyStat, 548 MeResponse, 549 OnboardingIntentRequest, 550 ProfilePatchRequest, 551 ProfileStats, 552 ReauthenticateRequest, 553 ReauthenticationOpened, 554 TagStat, 555 TrendPoint, 556 UpdateUserRoleRequest, 557 UpdateUserStandingsRequest, 558 UserInfo, 559 UserSolveTrend, 560) 561 562__all__ = [ 563 "MAX_FLAGS_PER_SUBMISSION", 564 "PROFILE_VISIBILITY_KEYS", 565 "RUN_ARTIFACT_BLOCKED", 566 "RUN_TRAFFIC_BLOCKED", 567 "RUN_TRANSCRIPT_BLOCKED", 568 "RUN_WALL_BLOCKED", 569 "RUN_WRITEUP_BLOCKED", 570 "TRANSCRIPT_REFUSALS", 571 "WALL_REFUSALS", 572 "AccountDeletionAccepted", 573 "AccountKeyRow", 574 "AchievementCatalogEntry", 575 "AchievementCopy", 576 "AchievementProgress", 577 "AchievementSummary", 578 "Activity", 579 "ActivityTimeseries", 580 "ActivityTimeseriesBucket", 581 "AddEmailRequest", 582 "AdminArtifactStorageFlag", 583 "AdminAuthoredChallengeDetail", 584 "AdminAuthoredChallengeRow", 585 "AdminAuthoredRevisionRow", 586 "AdminChallengeLastError", 587 "AdminChallengeLatencyBucket", 588 "AdminChallengeQuestions", 589 "AdminChallengeStatsRow", 590 "AdminChallengeTimeseries", 591 "AdminChallengeTimeseriesBucket", 592 "AdminChangelogEntryInfo", 593 "AdminCompetitionAttachmentsResponse", 594 "AdminFeedbackRow", 595 "AdminHarnessRow", 596 "AdminHealthFlags", 597 "AdminOverview", 598 "AdminOverviewCounts", 599 "AdminPatchSubmissionDetail", 600 "AdminPatchSubmissionInfo", 601 "AdminPatchSubmissionPage", 602 "AdminRecentError", 603 "AdminSilentChallenge", 604 "AdminSolveCell", 605 "AdminSolveMatrix", 606 "AdminSolveMatrixChallenge", 607 "AdminSolveMatrixTeam", 608 "AdminStuckInstance", 609 "AdminTaskInfo", 610 "AdminTaskListResponse", 611 "AdminTaskLogLine", 612 "AdminTaskLogsResponse", 613 "AdminTaskSubmitRequest", 614 "AdminTeamRow", 615 "AdminTimeseries", 616 "AdminTimeseriesBucket", 617 "AdminTimeseriesMetric", 618 "AdminTokenRevokeResponse", 619 "AdminTrafficInstanceRow", 620 "AdminTrafficSummary", 621 "AdminTrafficTeamRow", 622 "AdminUnhealthyNode", 623 "AdminUserInfo", 624 "AffiliationInfo", 625 "AffiliationSelection", 626 "AgentActionRow", 627 "AgentActionsPage", 628 "AgentEntrantInfo", 629 "AgentPulseBeat", 630 "AgentPulseLane", 631 "AgentPulsePage", 632 "AnnouncementCreate", 633 "AnnouncementInfo", 634 "AnnouncementUpdate", 635 "AnswerTraceHit", 636 "AnswerTraceRequest", 637 "AnswerTraceResponse", 638 "ArchiveStoreReport", 639 "AttachmentList", 640 "AuditFinding", 641 "AuthTokenResponse", 642 "AuthorReputation", 643 "AuthoredAuditReport", 644 "AuthoredAutoSmoke", 645 "AuthoredBuildCapability", 646 "AuthoredChallengeFileBody", 647 "AuthoredChallengeFileEntry", 648 "AuthoredChallengeFileRequest", 649 "AuthoredChallengeInfo", 650 "AuthoredChallengeReviewRequest", 651 "AuthoredChallengeRow", 652 "AuthoredCurationElsewhere", 653 "AuthoredCurationState", 654 "AuthoredDeclarations", 655 "AuthoredGraduationInfo", 656 "AuthoredQualityFlag", 657 "AuthoredReceptionRow", 658 "AuthoredReceptionState", 659 "AuthoredRevisionDiff", 660 "AuthoredRevisionFileChange", 661 "AuthoredRevisionInfo", 662 "AuthoredRevisionRow", 663 "AuthoringDivision", 664 "AuthoringReceptionInfo", 665 "AuthoringReviewerInfo", 666 "AuthoringScaleInfo", 667 "AuthoringTemplateFile", 668 "AuthoringTemplateManifest", 669 "AwdArenaGate", 670 "AwdArenaGateLink", 671 "AwdArenaLink", 672 "AwdArenaPeerRow", 673 "AwdArenaPeersResponse", 674 "AwdAttackAnchorResponse", 675 "AwdAttackAnchorRow", 676 "AwdAttackMatrixResponse", 677 "AwdAttackMatrixRow", 678 "AwdBoardEntry", 679 "AwdBoardResponse", 680 "AwdCaptureCorroborationRow", 681 "AwdClockBrief", 682 "AwdCorroborationResponse", 683 "AwdDefenceAnchorResponse", 684 "AwdDefenceAnchorRow", 685 "AwdDefenceTimelineCell", 686 "AwdDefenceTimelineResponse", 687 "AwdDefenceTimelineRow", 688 "AwdEventRow", 689 "AwdEventsResponse", 690 "AwdFlagResult", 691 "AwdFlagSubmitRequest", 692 "AwdFlagSubmitResponse", 693 "AwdForensicAnalysis", 694 "AwdForensicsResponse", 695 "AwdForensicsRow", 696 "AwdMatchConfig", 697 "AwdMatchInfo", 698 "AwdMatchStatus", 699 "AwdProvisionPlanInfo", 700 "AwdRoundHistoryResponse", 701 "AwdRoundPoint", 702 "AwdRoundSeries", 703 "AwdSlaHistoryResponse", 704 "AwdSlaProbeRow", 705 "AwdTarget", 706 "AwdTargetsResponse", 707 "AwdVpnPeer", 708 "AwdVpnRequest", 709 "AwdVpnStatus", 710 "BigLimitOffsetPage", 711 "BigLimitOffsetPageOf100", 712 "BlueprintHostInfo", 713 "BlueprintMilestoneInfo", 714 "BoardCriterion", 715 "BuildPushScopeRequest", 716 "BulkInviteRequest", 717 "BulkInviteResponse", 718 "BulkInviteResultRow", 719 "CalendarBucket", 720 "ChallengeBlueprint", 721 "ChallengeBuildKickoffNodeResult", 722 "ChallengeBuildKickoffResponse", 723 "ChallengeBuildNodeState", 724 "ChallengeBuildStateResponse", 725 "ChallengeBuildStateRow", 726 "ChallengeFacetCount", 727 "ChallengeFacets", 728 "ChallengeFlagStats", 729 "ChallengeInfo", 730 "ChallengePullKickoffNodeResult", 731 "ChallengePullKickoffResponse", 732 "ChallengePullNodeState", 733 "ChallengePullStateResponse", 734 "ChallengePullStateRow", 735 "ChallengeRescanNodeResult", 736 "ChallengeRescanResult", 737 "ChallengeSolveAttempt", 738 "ChallengeSolveAttemptsResponse", 739 "ChallengeStats", 740 "ChallengeTeamSolveSummary", 741 "ChallengeValuePoint", 742 "ChangelogAnnounceRequest", 743 "ChangelogAnnounceResponse", 744 "ChangelogEntryCreate", 745 "ChangelogEntryInfo", 746 "ChangelogEntryUpdate", 747 "ChangelogImportEntry", 748 "ChangelogImportRequest", 749 "ChangelogImportResponse", 750 "ChangelogLatestResponse", 751 "ChangelogListResponse", 752 "ChangelogSeenResponse", 753 "CheckAnswerRequest", 754 "CheckAnswerResponse", 755 "ClusterInfo", 756 "CompetitionAdminInfo", 757 "CompetitionApplicationInfo", 758 "CompetitionApplicationRequest", 759 "CompetitionApplicationReviewRequest", 760 "CompetitionApplicationRow", 761 "CompetitionAttachmentInfo", 762 "CompetitionAttachmentUpdate", 763 "CompetitionAttachmentsResponse", 764 "CompetitionChallengeBreakdown", 765 "CompetitionChallengeRow", 766 "CompetitionCreate", 767 "CompetitionDetail", 768 "CompetitionFormatPreset", 769 "CompetitionInfo", 770 "CompetitionInviteInfo", 771 "CompetitionMembershipInfo", 772 "CompetitionRecords", 773 "CompetitionRegistrationInfo", 774 "CompetitionRoster", 775 "CompetitionRosterTeam", 776 "CompetitionScoreDistribution", 777 "CompetitionScoreHistory", 778 "CompetitionSeriesConfig", 779 "CompetitionSeriesInfo", 780 "CompetitionSessionInfo", 781 "CompetitionUpdate", 782 "ContributorRow", 783 "CreateFineGrainedTokenRequest", 784 "CreateFineGrainedTokenResponse", 785 "CreateInviteRequest", 786 "CreateInviteResponse", 787 "CreditedChallengeRow", 788 "DeceptionStats", 789 "DefenceInfo", 790 "DefenceLiveFile", 791 "DefenceMatrixResponse", 792 "DefenceMatrixRow", 793 "DefenceSource", 794 "DeleteMeRequest", 795 "DeploymentConfig", 796 "DeviceApproveRequest", 797 "DeviceCodeResponse", 798 "DeviceInfoResponse", 799 "DeviceTokenRequest", 800 "DeviceTokenResponse", 801 "DifficultyStat", 802 "EasterEggClaim", 803 "EmailCodeAuthInfo", 804 "EmailSuppressionCreate", 805 "EmailSuppressionInfo", 806 "EmailSuppressionListResponse", 807 "EmailTemplateInfo", 808 "EmailTemplateListResponse", 809 "EmailVerificationSent", 810 "EmailVerificationStatus", 811 "ErrorResponse", 812 "EvalAllowanceGrant", 813 "EvalAllowanceRow", 814 "EvalArtifactBucket", 815 "EvalArtifactReport", 816 "EvalEntryRequest", 817 "EvalLeaderboard", 818 "EvalRunCoverage", 819 "EvalTrendPoint", 820 "FeedbackStats", 821 "GauntletBuildCost", 822 "GauntletBuildStep", 823 "GauntletLibraryResponse", 824 "GauntletModuleInfo", 825 "GauntletPlanRequest", 826 "GauntletPlanResponse", 827 "GauntletPreset", 828 "GauntletPreviewRequest", 829 "GauntletPreviewResponse", 830 "GauntletRangeCreate", 831 "GauntletRangeCreated", 832 "GauntletRangeInfo", 833 "GauntletRangeListResponse", 834 "GauntletShortfall", 835 "GrantAuthoringReviewerRequest", 836 "GrantCompetitionAdminRequest", 837 "HarnessModelCell", 838 "HarnessStanding", 839 "HarnessTags", 840 "HarnessUpdate", 841 "HealthResponse", 842 "ImageConsistencyRow", 843 "InboxAnnouncement", 844 "InboxCaptainRequest", 845 "InboxCompetitionInvite", 846 "InboxIncomingInvite", 847 "InboxOutgoingRequest", 848 "InboxResponse", 849 "InstanceAnswersResponse", 850 "InstanceInfo", 851 "InstanceQuestionInfo", 852 "InstanceRecordArtifacts", 853 "InstanceRecordDetail", 854 "InstanceRecordInfo", 855 "InstanceRecordInfoDetail", 856 "InstanceStatusResponse", 857 "InstanceStorageReport", 858 "InstanceTrafficPage", 859 "InstanceTrafficWitnesses", 860 "InstitutionCreate", 861 "InstitutionInfo", 862 "InstitutionPage", 863 "InstitutionSuggestion", 864 "InstitutionSuggestionPage", 865 "LeaderboardCell", 866 "LimitOffsetPageUpTo200", 867 "LimitOffsetPageUpTo500", 868 "LinkStartResponse", 869 "LinkedIdentity", 870 "LlmBudgetPage", 871 "LlmBudgetResetInfo", 872 "LlmBudgetResetRequest", 873 "LlmBudgetRow", 874 "LlmCallBody", 875 "LlmCallPage", 876 "LlmCallRow", 877 "LlmModelOption", 878 "LlmProviderChoice", 879 "LlmProviderCreate", 880 "LlmProviderInfo", 881 "LlmProviderPage", 882 "LlmProviderProbe", 883 "LoginRequest", 884 "MeResponse", 885 "MetaAgents", 886 "MetaAuthoredArchive", 887 "MetaChallenges", 888 "MetaContribute", 889 "MetaEvents", 890 "MetaInstanceTtl", 891 "MetaLlm", 892 "MetaMine", 893 "MetaOps", 894 "MetaPlatform", 895 "MetaResponse", 896 "MilestoneProgress", 897 "ModelStanding", 898 "MyAchievementsResponse", 899 "MyEvalRunPage", 900 "MyEvalRunRow", 901 "MyLlmKey", 902 "MyLlmKeyCreate", 903 "MyLlmKeyProbe", 904 "MyReactionsResponse", 905 "MyRegistrationResponse", 906 "MyReportRow", 907 "MyReportsResponse", 908 "MySolveSummary", 909 "NodeAuthoredPush", 910 "NodeAuthoredResult", 911 "NodeAuthoredSmokePush", 912 "NodeAuthoredSmokeResult", 913 "NodeAuthoredSyncLine", 914 "NodeCaptureStoreReport", 915 "NodeCaptureStoreRow", 916 "NodeHeartbeat", 917 "NodeHeartbeatResponse", 918 "NodeInfo", 919 "NodeInviteInfo", 920 "NodePatch", 921 "NodeRangePush", 922 "NodeRangeResult", 923 "NodeRangeSyncLine", 924 "NodeRangeVerifyPush", 925 "NodeRangeVerifyResult", 926 "NodeRegister", 927 "NodeRegisterResponse", 928 "NodeRegistryTicket", 929 "NodeRegistryTicketRequest", 930 "NotificationCategoryInfo", 931 "NotificationChannelCell", 932 "NotificationPreferenceUpdate", 933 "NotificationPreferencesResponse", 934 "OAuthProviderInfo", 935 "OnboardingIntentRequest", 936 "PasswordAuthInfo", 937 "PatchStepInfo", 938 "PatchSubmissionCreate", 939 "PatchSubmissionInfo", 940 "PlatformSettingInfo", 941 "PlatformSettingPatch", 942 "PlayerShellCreate", 943 "PlayerShellTicket", 944 "ProfilePatchRequest", 945 "ProfileStats", 946 "ProvidersResponse", 947 "PublicTranscriptBlock", 948 "PublicTranscriptResponse", 949 "PublicTranscriptTurn", 950 "QaChallengeView", 951 "QaSubmissionCreate", 952 "QuestionAttemptResetInfo", 953 "QuestionAttemptResetRequest", 954 "QuestionCoverageRow", 955 "QuestionPublicInfo", 956 "RangeProgress", 957 "RangeProgressRow", 958 "RangeReplay", 959 "RangeReplayFrame", 960 "RangeReplayPosition", 961 "ReactionCount", 962 "ReauthenticateRequest", 963 "ReauthenticationOpened", 964 "RecentUnlock", 965 "RecordAuthoredChallenge", 966 "RecordEvalRun", 967 "RecordSolve", 968 "RecordSubmission", 969 "RecordTeam", 970 "RegisterRequest", 971 "RegistrationFieldSpec", 972 "RegistrationFormSpec", 973 "RegistrationRosterRow", 974 "RegistrationSubmitRequest", 975 "RegistrationSummary", 976 "RegistrationTeamRow", 977 "RegistryGcStatus", 978 "RegistryManifestRow", 979 "RegistryPushTarget", 980 "RegistryQuotaInfo", 981 "RegistryReclaimItem", 982 "RegistryReclaimPlan", 983 "RegistryReclaimRequest", 984 "RegistryRepo", 985 "RegistryRepoList", 986 "RegistryTagList", 987 "RegistryUsageReport", 988 "RegistryUsageRow", 989 "RenewResponse", 990 "ReportAttachmentInfo", 991 "ReportAttachmentsResponse", 992 "ReportClaimRow", 993 "ReportClaimsPage", 994 "ReportGradeInfo", 995 "ReportGradeSummary", 996 "ReportGradesResponse", 997 "ReportInfo", 998 "ReportPipeline", 999 "ReportPipelineEvent", 1000 "ReportRosterResponse", 1001 "ReportRosterRow", 1002 "ReportSaveRequest", 1003 "ReportSaveResponse", 1004 "ReportStanding", 1005 "ReportVersionsResponse", 1006 "RevealedAnswer", 1007 "ReviewDecisionRequest", 1008 "RunTranscriptRequest", 1009 "RunWallRequest", 1010 "RunWriteup", 1011 "ScheduledJobInfo", 1012 "ScheduledJobPatch", 1013 "ScopeCatalogEntry", 1014 "ScoreBucket", 1015 "ScoreHistoryPoint", 1016 "ScoreHistorySeries", 1017 "ScoreboardEntry", 1018 "SeriesOccurrencePreview", 1019 "SeriesPreview", 1020 "SetPasswordRequest", 1021 "ShellRecordingInfo", 1022 "SshCredential", 1023 "SshExchangeRequest", 1024 "StarGazerVerifyResponse", 1025 "StartRequest", 1026 "StartResponse", 1027 "StopResponse", 1028 "SubmissionCreate", 1029 "SubmissionResponse", 1030 "TagStat", 1031 "TeamAchievement", 1032 "TeamAffiliationSelection", 1033 "TeamCaptainRequest", 1034 "TeamDetail", 1035 "TeamDisqualifyRequest", 1036 "TeamInfo", 1037 "TeamInviteInfo", 1038 "TeamLogoUploadResponse", 1039 "TeamMemberInfo", 1040 "TeamStandingInfo", 1041 "TestEmailRequest", 1042 "TestEmailResult", 1043 "TokenInfo", 1044 "TokenLifetimeRequest", 1045 "TokenScopeUpdate", 1046 "TokenScopesResponse", 1047 "TokenTemplatesResponse", 1048 "TokenTraceLink", 1049 "TokenTraceRequest", 1050 "TokenTraceResponse", 1051 "TrafficWitness", 1052 "TrendPoint", 1053 "UpdateUserRoleRequest", 1054 "UpdateUserStandingsRequest", 1055 "UserEmailInfo", 1056 "UserInfo", 1057 "UserScoreboardEntry", 1058 "UserSolveTrend", 1059 "VendorStandardSet", 1060 "VendorStanding", 1061 "VerifyAddedEmailRequest", 1062 "VerifyAnswerRequest", 1063 "VerifyAnswerResponse", 1064 "VerifyEmailRequest", 1065 "VirtualGhostBoard", 1066 "VirtualGhostRow", 1067]
411class AccountDeletionAccepted(CtfyModel): 412 """What ``DELETE /me`` did — carried out, or scheduled. 413 414 ⚠️ **A bare 204 could not say which**, and they are opposite facts 415 with opposite next steps: one reader has nothing left to do, the 416 other has a window in which signing in cancels the whole thing. That 417 is the shape ``make_probe`` and ``AttackOutcome`` both record — a 418 response the caller cannot act on differently is a response that 419 answered a different question. 420 421 ⚠️ **``deleted`` defaults ``False`` on both sides, and the asymmetry 422 is the argument.** A producer that forgets tells the caller their 423 account is *scheduled* when it is gone: they try to sign in, fail, 424 and learn the truth. The other default tells them it is *gone* when 425 it is recoverable, so they never try — and that mistake erases the 426 account by inaction, which is the one this whole feature exists to 427 prevent. 428 """ 429 430 #: True when the cascade already ran inside the request — which is 431 #: what a grace of ``0`` means, and what this platform did before 432 #: the window existed. 433 deleted: bool = False 434 #: When the deletion becomes final. Equal to the request instant on 435 #: the ``deleted`` branch, so a reader never has to treat the two 436 #: cases as different shapes. Required: a default here would be a 437 #: new claim about an instant. 438 effective_at: AwareDatetime
What DELETE /me did — carried out, or scheduled.
⚠️ A bare 204 could not say which, and they are opposite facts
with opposite next steps: one reader has nothing left to do, the
other has a window in which signing in cancels the whole thing. That
is the shape make_probe and AttackOutcome both record — a
response the caller cannot act on differently is a response that
answered a different question.
⚠️ deleted defaults False on both sides, and the asymmetry
is the argument. A producer that forgets tells the caller their
account is scheduled when it is gone: they try to sign in, fail,
and learn the truth. The other default tells them it is gone when
it is recoverable, so they never try — and that mistake erases the
account by inaction, which is the one this whole feature exists to
prevent.
656class AccountKeyRow(CtfyModel): 657 """One account's own credential, on the operator's merged page. 658 659 ⚠️ **No secret and no fingerprint**, unlike the fleet row above: four 660 characters of somebody else's credential buys the operator nothing — 661 they are reading *who pays*, not telling two of one person's keys 662 apart — and withholding them is what lets the whole listing be a 663 column projection that decrypts nothing. See 664 :class:`~ctfy.core.state.models.OwnedLlmKeySummary`. 665 666 ⚠️ **No Test button either, which is why the stored verdict is 667 here.** A probe spends somebody else's upstream account, so the 668 operator reads what the *owner's* last press established — with its 669 instant, because a code with no date reads as current for ever. 670 671 ⚠️ Served in a plain ``BigLimitOffsetPage``, deliberately not a 672 bespoke envelope like :class:`LlmProviderPage`: that one carries two 673 facts about the *deployment* (is there a gateway, can a secret be 674 sealed) which have nowhere else to ride. This carries none, and a 675 hand-written ``{items, total}`` would be a fourth page shape the 676 client's ``usePagination`` cannot read — it wants ``offset`` and 677 ``limit`` back as well, which is what tells a pager it is on the 678 last page rather than at the end of the list. 679 """ 680 681 owner_user_id: str = "" 682 #: ``display_name or email``, composed server-side. ⚠️ ``""`` means 683 #: *no name to show* — an account that has gone — which is a 684 #: different fact from a blank display name, and the id beside it is 685 #: what keeps the row addressable either way. 686 owner_display_name: str = "" 687 flavor: FlavorOrUnset = "" 688 base_url: str = "" 689 label: str = "" 690 added_at: str = "" 691 #: ⚠️ ``""`` is *never spent by a run*, not "a while ago": for an 692 #: operator asking why an entrant's agent produced nothing, those are 693 #: opposite answers. 694 last_used_at: str = "" 695 last_probe_code: str = "" 696 last_probe_at: str = ""
One account's own credential, on the operator's merged page.
⚠️ No secret and no fingerprint, unlike the fleet row above: four
characters of somebody else's credential buys the operator nothing —
they are reading who pays, not telling two of one person's keys
apart — and withholding them is what lets the whole listing be a
column projection that decrypts nothing. See
~ctfy.core.state.models.OwnedLlmKeySummary.
⚠️ No Test button either, which is why the stored verdict is here. A probe spends somebody else's upstream account, so the operator reads what the owner's last press established — with its instant, because a code with no date reads as current for ever.
⚠️ Served in a plain BigLimitOffsetPage, deliberately not a
bespoke envelope like LlmProviderPage: that one carries two
facts about the deployment (is there a gateway, can a secret be
sealed) which have nowhere else to ride. This carries none, and a
hand-written {items, total} would be a fourth page shape the
client's usePagination cannot read — it wants offset and
limit back as well, which is what tells a pager it is on the
last page rather than at the end of the list.
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.
18class Activity(CtfyModel): 19 """A single activity event in the platform log.""" 20 21 id: str = "" 22 timestamp: datetime | None = None 23 event: str # See PlatformEvent enum for valid values 24 team_id: str = "" 25 team_name: str = "" 26 challenge_id: str = "" 27 # Who triggered this event — surfaced on the public feed so admins 28 # can filter by actor and auditors can trace back. Values mirror 29 # the ActivityState fields in core.state.models. 30 actor_id: str = "" 31 actor_name: str = "" 32 actor_type: str = "" 33 # Denormalised actor avatar (GitHub/Google), surfaced on the feed so 34 # each event can render the actor's real face. Empty ⇒ the frontend 35 # renders a generated initials chip instead. 36 actor_avatar_url: str = "" 37 detail: ActivityDetail = Field(default_factory=ActivityDetail)
A single activity event in the platform log.
55class ActivityTimeseries(CtfyModel): 56 """Bucketed activity counts for the per-team activity histogram. 57 58 Same ``ts`` semantics as ``AdminTimeseries`` — oldest-first, right 59 edge anchored to "now". ``events`` is the sorted union of event 60 types that appear in any bucket, so the frontend has a stable list 61 of stack segments to render even when individual buckets are empty. 62 """ 63 64 window_s: int = 0 65 bucket_s: int = 0 66 events: list[str] = Field(default_factory=list) 67 buckets: list[ActivityTimeseriesBucket] = Field(default_factory=list)
Bucketed activity counts for the per-team activity histogram.
Same ts semantics as AdminTimeseries — oldest-first, right
edge anchored to "now". events is the sorted union of event
types that appear in any bucket, so the frontend has a stable list
of stack segments to render even when individual buckets are empty.
40class ActivityTimeseriesBucket(CtfyModel): 41 """One bucket of the team's activity histogram. 42 43 ``counts`` is keyed by event type (``flag_correct``, ``instance_started``, 44 …) so the frontend can stack each event as its own bar segment without 45 a follow-up shape-change to add new event types. 46 47 See :class:`AdminTimeseriesBucket` for the ``partial`` semantics. 48 """ 49 50 ts: float = 0.0 # right edge, Unix seconds 51 counts: dict[str, int] = Field(default_factory=dict) 52 partial: bool = False
One bucket of the team's activity histogram.
counts is keyed by event type (flag_correct, instance_started,
…) so the frontend can stack each event as its own bar segment without
a follow-up shape-change to add new event types.
See AdminTimeseriesBucket for the partial semantics.
210class AddEmailRequest(CtfyModel): 211 """Body for ``POST /me/emails`` — start proving a *new* address. 212 213 ⚠️ **This one does carry an address, and that is the whole 214 difference from ``VerifyEmailRequest``.** Its sibling refuses an 215 address field because the code there goes to the account's stored 216 mailbox and a parameter would let a caller mail anywhere and attach 217 anyone's address as proved. Here the code goes *to the address in 218 this body*, so it proves nothing unless the caller can read that 219 mailbox — the parameter is the act, not a bypass of it. 220 """ 221 222 email: EmailStr
Body for POST /me/emails — start proving a new address.
⚠️ This one does carry an address, and that is the whole
difference from VerifyEmailRequest. Its sibling refuses an
address field because the code there goes to the account's stored
mailbox and a parameter would let a caller mail anywhere and attach
anyone's address as proved. Here the code goes to the address in
this body, so it proves nothing unless the caller can read that
mailbox — the parameter is the act, not a bypass of it.
212class AdminArtifactStorageFlag(CtfyModel): 213 """The archive store is big enough that somebody should look. 214 215 ⚠️ **``verdict`` carries *why*, not just *that*.** "50 GB with a 216 30-day window" is a steady state somebody chose; "50 GB and nothing 217 reclaims it" is a ramp. The advice differs — shorten the window, or 218 set one at all — and a bare number cannot tell them apart. 219 """ 220 221 #: ``growing`` = over the threshold with no retention window; 222 #: ``over`` = a window exists and is not keeping up. 223 verdict: str = "" 224 stored_runs: int = 0 225 stored_bytes: int = 0 226 alert_bytes: int = 0 227 retention_s: int = 0
The archive store is big enough that somebody should look.
⚠️ verdict carries why, not just that. "50 GB with a
30-day window" is a steady state somebody chose; "50 GB and nothing
reclaims it" is a ramp. The advice differs — shorten the window, or
set one at all — and a bare number cannot tell them apart.
704class AdminAuthoredChallengeDetail(CtfyModel): 705 """One upload, opened. 706 707 The queue row plus what is actually in the tree. Reading one row is 708 what makes carrying the file list affordable here and not on the 709 listing. 710 """ 711 712 row: AdminAuthoredChallengeRow 713 files: list[AuthoredChallengeFileEntry] = Field(default_factory=list) 714 total_bytes: int = 0 715 #: Whether publishing asks a platform admin to confirm they read the 716 #: files marked ``executes`` (plan P2.3) — true exactly when any file 717 #: is marked. ⚠️ Defaults ``False``, and the publish route asks the 718 #: question itself, so an older client that sends no confirmation is 719 #: refused with the reason rather than let through. 720 needs_exec_review: bool = False
One upload, opened.
The queue row plus what is actually in the tree. Reading one row is what makes carrying the file list affordable here and not on the listing.
554class AdminAuthoredChallengeRow(AuthoredDeclarations): 555 """One upload as the operator's review queue lists it. 556 557 ⚠️ **Composed server-side, and the reason is ``AdminHarnessRow``'s.** 558 That queue "could not answer any of the three questions an approval 559 turns on": the author was a uuid, the reviewer was a uuid, and the 560 gate was recomputed. This one names both people, because deciding 561 whether to run somebody's code starts with knowing whose it is — 562 ``team_id`` is a uuid and a table of uuids is a table nobody triages. 563 564 ⚠️ **The tree is not here, and neither is its size.** The queue is 565 over a whole event; the bytes and the file list belong to 566 :class:`AdminAuthoredChallengeDetail`, which reads one row. See 567 ``AuthoredChallengeSummary`` for why a *cheap* size column is worse 568 than none. 569 570 ⚠️ ``/admin`` is outside `test_anonymity_doors`' scope on purpose — 571 the organiser is precisely who sees through an anonymous entry — so 572 ``team_name`` travels here where it may not on a board. 573 """ 574 575 id: str 576 #: What the author called it. ⚠️ Unique per ``(competition_id, 577 #: team_id)`` and no further — see `AuthoredChallengeRow.slug`. 578 slug: str = "" 579 #: ``metadata.yaml``'s own ``name:``, read at ingest. ``""`` means 580 #: the metadata did not say; a reader falls back to `slug`, never 581 #: to the id. 582 title: str = "" 583 competition_id: str 584 #: ``""`` when the competition has been deleted out from under the 585 #: row: ``authored_challenges`` is in ``_KEEP_ON_DELETE``, so a 586 #: reviewer can still see what was submitted and to what. 587 competition_title: str = "" 588 team_id: str 589 #: ⚠️ Blank means *this team is gone*, never *unnamed*. Rendered as 590 #: the id rather than as an empty cell, or a deleted team's uploads 591 #: read as somebody else's. 592 team_name: str = "" 593 user_id: str 594 author_display_name: str = "" 595 submitted_at: AwareDatetime | None = None 596 review_status: ReviewStatus = "pending" 597 #: The reviewing operator's id, ``""`` while nobody has ruled. 598 reviewed_by: str = "" 599 reviewed_by_display_name: str = "" 600 reviewed_at: AwareDatetime | None = None 601 review_note: str = "" 602 #: Whether the tree is in this deployment's catalog, **read off the 603 #: disk** rather than from a column. 604 #: 605 #: ⚠️ There is deliberately no ``published_at``, exactly as 606 #: `GeneratedRangeState` has no status column: the directory is the 607 #: fact, and a column beside it is a second claim free to disagree 608 #: the moment anything removes the tree. ``review_status`` says what 609 #: an operator decided; this says what is there. 610 #: 611 #: ⚠️ Defaults **False**, the loud direction: an older server or a 612 #: producer that forgets reports *not published*, which costs a press 613 #: that then refuses on the id. The other default would tell an 614 #: operator their approved batch is live when nothing is on disk. 615 in_catalog: bool = False 616 #: Where it stands with **this event's** challenge set — the last 617 #: step of the chain, and the one nothing told the operator about. 618 #: 619 #: ⚠️ **Not ``in_catalog`` a second time.** That one is a fact about 620 #: this deployment's disk; an approved, published, node-pushed, 621 #: smoke-passing challenge that no organiser curated is live in the 622 #: catalog and in front of nobody, and pays its author nothing. 623 #: Measured before this field: ``not_curated`` reached the author on 624 #: four surfaces and **zero** organiser surfaces — the review queue 625 #: did not name ``challenge_ids`` at all. 626 #: 627 #: ⚠️ Defaults ``not_curated``, the direction that reads as 628 #: unfinished: a producer that did not ask must not report an upload 629 #: as being in front of entrants. 630 curation: AuthoredCurationState = "not_curated" 631 #: The competitions **other than this upload's own** whose curated 632 #: set lists it — where it went after the event it was written for 633 #: (plan P1.5). ``curation`` above stays the own-event answer. 634 #: 635 #: ⚠️ From the **same producer** for both audiences 636 #: (`authoring_curation.curated_elsewhere`), `curation`'s rule — but 637 #: the author's copy names only competitions the author may see, so 638 #: an upload is not how a hidden event's name reaches somebody it is 639 #: hidden from. The operator's copy names all of them. 640 #: 641 #: ⚠️ Empty by default, and empty is a real answer: nothing curated 642 #: it anywhere else. A bucket competition never appears, because 643 #: nothing is curated into a set resolved from a catalog directory. 644 curated_elsewhere: list[AuthoredCurationElsewhere] = Field(default_factory=list) 645 #: What the last smoke run on a worker found, ``""`` if none has run. 646 #: 647 #: ⚠️ **On the row rather than only in the task record**, and the 648 #: reason is what an approval is for. An operator presses smoke and 649 #: the verdict came back as a `TaskResult` — so reading it later 650 #: meant finding that task, and re-reviewing a challenge meant 651 #: taking somebody's word for whether it had ever been run. It is 652 #: the same column the author reads; the two audiences act on it 653 #: differently (fix it, versus decide about it) and neither can act 654 #: without seeing it. 655 #: 656 #: ⚠️ **Usually ``""`` in this queue, and that is correct rather 657 #: than useless.** Smoking needs an approved tree pushed to a node, 658 #: so a row awaiting a first verdict has never been smoked — the 659 #: blank is the honest state, and the column earns itself on the 660 #: second look at a challenge rather than the first. 661 smoke_verdict: AuthoredSmokeState = "" 662 smoke_at: AwareDatetime | None = None 663 #: A smoke run queued or running for this tree — see 664 #: `AuthoredChallengeRow.smoke_in_flight`. What stops a reviewer 665 #: pressing smoke for a run that is already on its way, and what the 666 #: queue's ``in_flight`` filter selects. 667 smoke_in_flight: AuthoredSmokeInFlightState = "" 668 #: The platform admin who read this upload's execution surface at 669 #: publish (plan P2.3), ``""`` if nobody has — including every upload 670 #: published before the stamp existed, which the row does not claim. 671 exec_reviewed_by: str = "" 672 #: What to call ``exec_reviewed_by``; ``""`` beside a blank id. 673 exec_reviewed_by_display_name: str = "" 674 exec_reviewed_at: AwareDatetime | None = None 675 #: Players' reports that reached the threshold (plan P2.6), in reading 676 #: order. ⚠️ Empty is two facts — nobody reported enough, or the 677 #: threshold is off — and both mean *nothing to act on here*, the only 678 #: thing a queue reader asks of it. 679 quality_flags: list[AuthoredQualityFlag] = Field(default_factory=list) 680 #: The revision proposed for this upload and not yet applied, or 681 #: ``None`` (plan P3.1) — what the queue's ``revision`` filter selects. 682 revision: AdminAuthoredRevisionRow | None = None 683 #: How players received this uploader's published challenges (plan 684 #: P3.5), as this reader may attribute them. ``None`` is *nobody to 685 #: read* — the uploader's account is gone — and never the same thing as 686 #: a newcomer's reading, which says ``published: 0``. 687 author_reputation: AuthorReputation | None = None
One upload as the operator's review queue lists it.
⚠️ Composed server-side, and the reason is AdminHarnessRow's.
That queue "could not answer any of the three questions an approval
turns on": the author was a uuid, the reviewer was a uuid, and the
gate was recomputed. This one names both people, because deciding
whether to run somebody's code starts with knowing whose it is —
team_id is a uuid and a table of uuids is a table nobody triages.
⚠️ The tree is not here, and neither is its size. The queue is
over a whole event; the bytes and the file list belong to
AdminAuthoredChallengeDetail, which reads one row. See
AuthoredChallengeSummary for why a cheap size column is worse
than none.
⚠️ /admin is outside test_anonymity_doors' scope on purpose —
the organiser is precisely who sees through an anonymous entry — so
team_name travels here where it may not on a board.
166class AdminAuthoredRevisionRow(AuthoredRevisionRow): 167 """A revision as a reviewer reads it: who ruled and who applied, named.""" 168 169 #: The live version it was proposed against — the upload's 170 #: ``submitted_at`` at that moment. 171 base_submitted_at: AwareDatetime | None = None 172 reviewed_by: str = "" 173 reviewed_by_display_name: str = "" 174 applied_by: str = "" 175 applied_by_display_name: str = ""
A revision as a reviewer reads it: who ruled and who applied, named.
111class AdminChallengeLastError(CtfyModel): 112 """Most-recent error against a challenge, surfaced on the per-challenge 113 admin row so an admin can spot a broken challenge without opening 114 instance history.""" 115 116 ts: float = 0.0 # stopped_at of the failing instance 117 message: str = "" 118 instance_id: str = ""
Most-recent error against a challenge, surfaced on the per-challenge admin row so an admin can spot a broken challenge without opening instance history.
156class AdminChallengeLatencyBucket(CtfyModel): 157 """One fixed bucket of the cluster-wide launch-duration histogram. 158 159 The Challenges admin table paginates server-side, so the pooled 160 "how slow are launches overall" picture can't be re-derived in the 161 browser from one page of rows — this endpoint pools every 162 challenge's recent successful launches and bins them with the same 163 absolute edges the per-row drawer uses, so cross-page the shape 164 stays comparable. 165 """ 166 167 label: str 168 count: int = 0
One fixed bucket of the cluster-wide launch-duration histogram.
The Challenges admin table paginates server-side, so the pooled "how slow are launches overall" picture can't be re-derived in the browser from one page of rows — this endpoint pools every challenge's recent successful launches and bins them with the same absolute edges the per-row drawer uses, so cross-page the shape stays comparable.
309class AdminChallengeQuestions(CtfyModel): 310 """Every question a challenge declares, gated ones included. 311 312 The organiser's counterpart to the catalog projection, which omits 313 gated entries (see ``projections.question_infos``). Without this an 314 organiser running an exercise has no way to answer "which milestones 315 exist, and why did this team not get credit for one" short of reading 316 ``metadata.yaml`` off disk — and the corpus is a submodule the 317 deployment mounts read-only, not something the admin console can see. 318 319 Deliberately the **same** ``QuestionPublicInfo`` rows rather than a 320 wider admin model: this route changes *who may read*, not *what is 321 said*. The groundtruth is still never projected — a static ``answer`` 322 lives in metadata and a dynamic one is minted per instance, and 323 neither belongs on a page. ``AdminTeamRow`` is a separate model 324 because it adds a field the public one must not carry; there is no 325 such field here. 326 """ 327 328 challenge_id: str 329 questions: list[QuestionPublicInfo] = Field(default_factory=list) 330 # How many of ``questions`` are gated behind ``requires``. Served 331 # rather than derived so a client cannot disagree with the server 332 # about what the catalog is withholding. 333 gated_count: int = 0
Every question a challenge declares, gated ones included.
The organiser's counterpart to the catalog projection, which omits
gated entries (see projections.question_infos). Without this an
organiser running an exercise has no way to answer "which milestones
exist, and why did this team not get credit for one" short of reading
metadata.yaml off disk — and the corpus is a submodule the
deployment mounts read-only, not something the admin console can see.
Deliberately the same QuestionPublicInfo rows rather than a
wider admin model: this route changes who may read, not what is
said. The groundtruth is still never projected — a static answer
lives in metadata and a dynamic one is minted per instance, and
neither belongs on a page. AdminTeamRow is a separate model
because it adds a field the public one must not carry; there is no
such field here.
121class AdminChallengeStatsRow(CtfyModel): 122 """One row of the per-challenge admin dashboard. 123 124 Aggregates over ``InstanceRecord`` (lifetime) plus live ``InstanceState`` 125 (running). Launch-duration percentiles are computed only over rows with 126 both ``requested_at`` and ``ready_at`` populated — error rows that never 127 reached READY contribute to ``errors`` / ``error_rate`` but not to p50/p95. 128 129 ⚠️ "Lifetime" was false here until the route grouped in SQL: every 130 figure came from the newest 10,000 records of the whole archive. 131 """ 132 133 challenge_id: str 134 name: str = "" 135 difficulty: str = "" 136 # Spec metadata folded in so the admin table no longer has to join 137 # a second /challenges bulk fetch in the browser to show / sort / 138 # filter by these (it paginates server-side now). 139 category: str = "" 140 tags: list[str] = Field(default_factory=list) 141 question_count: int = 0 142 launches: int = 0 143 errors: int = 0 144 error_rate: float = 0.0 145 avg_launch_s: float = 0.0 146 p50_launch_s: float = 0.0 147 p95_launch_s: float = 0.0 148 solve_rate: float = 0.0 149 running_count: int = 0 150 last_error: AdminChallengeLastError | None = None 151 # Newest-first launch durations, capped at 20, used for the inline 152 # sparkline. Recorded only for successful launches. 153 recent_launch_durations: list[float] = Field(default_factory=list)
One row of the per-challenge admin dashboard.
Aggregates over InstanceRecord (lifetime) plus live InstanceState
(running). Launch-duration percentiles are computed only over rows with
both requested_at and ready_at populated — error rows that never
reached READY contribute to errors / error_rate but not to p50/p95.
⚠️ "Lifetime" was false here until the route grouped in SQL: every figure came from the newest 10,000 records of the whole archive.
330class AdminChallengeTimeseries(CtfyModel): 331 """Per-challenge bucketed health metrics for the admin drilldown. 332 333 Backs the row-level drawer on /admin/challenges that surfaces "did 334 this challenge's error rate spike at some specific time" without 335 forcing the operator to scrub through raw activity. 336 """ 337 338 challenge_id: str = "" 339 window_s: int = 0 340 bucket_s: int = 0 341 buckets: list[AdminChallengeTimeseriesBucket] = Field(default_factory=list)
Per-challenge bucketed health metrics for the admin drilldown.
Backs the row-level drawer on /admin/challenges that surfaces "did this challenge's error rate spike at some specific time" without forcing the operator to scrub through raw activity.
311class AdminChallengeTimeseriesBucket(CtfyModel): 312 """One bucket of the per-challenge health time-series. 313 314 Carries enough fields for the admin UI to render error-rate **and** 315 launch-duration trends from a single response — no need for the 316 frontend to issue three calls or do its own bucketing. 317 318 See :class:`AdminTimeseriesBucket` for the ``partial`` semantics. 319 """ 320 321 ts: float = 0.0 # right edge, Unix seconds 322 launches: int = 0 323 errors: int = 0 324 error_rate: float = 0.0 # errors / launches; 0 when no launches 325 p50_launch_s: float = 0.0 326 p95_launch_s: float = 0.0 327 partial: bool = False
One bucket of the per-challenge health time-series.
Carries enough fields for the admin UI to render error-rate and launch-duration trends from a single response — no need for the frontend to issue three calls or do its own bucketing.
See AdminTimeseriesBucket for the partial semantics.
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.
1236class AdminCompetitionAttachmentsResponse(CompetitionAttachmentsResponse): 1237 """The organiser's view: the files plus what the next upload may be.""" 1238 1239 #: Whether this deployment can store a file at all. ⚠️ Served rather 1240 #: than inferred from an empty list, which reads as "nothing uploaded" 1241 #: when it means "nobody can". 1242 uploads_enabled: bool = False 1243 #: How many more files fit; ``None`` when the cap is ``0`` (unlimited). 1244 remaining: int | None = None 1245 #: The per-competition cap in force; ``0`` is unlimited. 1246 limit: int = 0 1247 #: The largest file accepted, in bytes — the hot setting as it 1248 #: resolves now, so the form states the number the route enforces. 1249 max_bytes: int = 0
The organiser's view: the files plus what the next upload may be.
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.
272class AdminHarnessRow(CtfyModel): 273 """One registry row as the operator reviewing it reads it. 274 275 The registry is self-service, so this table is a queue of other 276 people's images — and until it carried the three fields below, the 277 only thing on screen about an image somebody asked us to run on our 278 own hardware was its reference. "Who uploaded this", "when", and 279 "would a dispatch actually reach it" are the first three questions 280 anyone asks before approving one, and every one of them needed a 281 second lookup the page could not make. 282 283 ⚠️ **A wire model, never ``HarnessState`` with fields bolted on.** 284 State models feed ``model_dump`` → ``to_orm``, which maps every 285 dumped key to a column — so a projected ``owner_display_name`` on 286 the state model is a fresh ``no such column`` the first time 287 anything writes the row back. The two projections live here, where 288 nothing persists them. 289 290 ⚠️ **Carried by the field set, never by a list of names** — see 291 ``admin_awd._info``, which named nine of the twelve fields it shared 292 with its state model and reported every armed match as unarmed. 293 """ 294 295 # -- the stored row. Every field ``HarnessState`` declares, because 296 # the admin route has always answered with the whole thing and 297 # narrowing it here would be a silent removal from the page. 298 id: str = "" 299 display_name: str = "" 300 image: str = "" 301 version: str = "" 302 notes: str = "" 303 active: bool = True 304 created_at: AwareDatetime | None = None 305 created_at_ts: float = 0.0 306 created_by_user_id: str = "" 307 owner_user_id: str = "" 308 review_status: RegistrationReviewStatus = "pending" 309 reviewed_by_user_id: str = "" 310 reviewed_at_ts: float = 0.0 311 review_note: str = "" 312 approved_digest: str = "" 313 source_url: WebUrl = "" 314 conformance_status: ConformanceStatus = "" 315 conformance_checked_at_ts: float = 0.0 316 conformance_digest: str = "" 317 conformance_error: str = "" 318 conformance_findings: list[dict[str, str]] = Field(default_factory=list) 319 320 # -- what the row alone cannot say ------------------------------- 321 #: The account that registered this image. 322 #: 323 #: ⚠️ ``""`` here is **not** one fact, and the pair is what 324 #: separates them: with ``owner_user_id`` also blank the row is the 325 #: platform's own and there is nobody to name; with an owner set, 326 #: the account is gone and the id is all that is left. A page that 327 #: read this field alone would label a deleted user's image 328 #: "platform image" — and a platform image is exempt from review, 329 #: which is the opposite of what a row whose author vanished should 330 #: read as. 331 owner_display_name: str = "" 332 #: Which operator passed the standing verdict. Same reading as 333 #: ``owner_display_name``: paired with ``reviewed_by_user_id``. 334 reviewed_by_display_name: str = "" 335 #: Whether a dispatch would actually be sent to this image. 336 #: 337 #: ⚠️ **Composed server-side, from the one predicate that decides 338 #: it.** ``is_dispatchable`` is not a restatement of ``review_status 339 #: == "approved"`` — an approved row with no ``approved_digest`` is 340 #: refused, and so is a rejected platform row — so a client 341 #: recomputing it would eventually disagree with the gate about 342 #: whether the image runs, and disagree *silently*: the operator 343 #: sees "approved", the queue never dispatches, and nothing on the 344 #: page names the digest. Same argument ``expects_report`` makes. 345 dispatchable: bool = False
One registry row as the operator reviewing it reads it.
The registry is self-service, so this table is a queue of other people's images — and until it carried the three fields below, the only thing on screen about an image somebody asked us to run on our own hardware was its reference. "Who uploaded this", "when", and "would a dispatch actually reach it" are the first three questions anyone asks before approving one, and every one of them needed a second lookup the page could not make.
⚠️ A wire model, never HarnessState with fields bolted on.
State models feed model_dump → to_orm, which maps every
dumped key to a column — so a projected owner_display_name on
the state model is a fresh no such column the first time
anything writes the row back. The two projections live here, where
nothing persists them.
⚠️ Carried by the field set, never by a list of names — see
admin_awd._info, which named nine of the twelve fields it shared
with its state model and reported every armed match as unarmed.
230class AdminHealthFlags(CtfyModel): 231 """Aggregated red-flag panels for the admin Overview "needs attention" 232 section. Every list is independently populated and may be empty.""" 233 234 stuck_starting: list[AdminStuckInstance] = Field(default_factory=list) 235 recent_errors: list[AdminRecentError] = Field(default_factory=list) 236 unhealthy_nodes: list[AdminUnhealthyNode] = Field(default_factory=list) 237 silent_challenges: list[AdminSilentChallenge] = Field(default_factory=list) 238 #: ⚠️ **A list of at most one**, to match its four neighbours rather 239 #: than make the panel special-case a scalar — and so the "is 240 #: anything red?" sum stays one expression. 241 artifact_storage: list[AdminArtifactStorageFlag] = Field(default_factory=list)
Aggregated red-flag panels for the admin Overview "needs attention" section. Every list is independently populated and may be empty.
360class AdminOverview(CtfyModel): 361 """Single aggregate payload for the admin landing page.""" 362 363 nodes: AdminOverviewCounts = Field(default_factory=AdminOverviewCounts) 364 config: DeploymentConfig = Field(default_factory=DeploymentConfig) 365 capacity: int = 0 366 running_instances: int = 0 367 teams_total: int = 0 368 solves_total: int = 0 369 solves_today: int = 0 370 # User-engagement counters — the "are people actually on the platform right 371 # now?" signal the infra/challenge/team metrics above don't answer. 372 # ``users_active_24h`` is distinct humans who triggered a write action 373 # (launch / submit / solve / join / answer) in the last 24h, not raw logins 374 # (pure browsing leaves no activity row). ``users_new_24h`` is registrations 375 # in the same window. All default to 0 so the field set stays additive. 376 users_total: int = 0 377 users_active_24h: int = 0 378 users_new_24h: int = 0 379 #: Accounts whose deletion deadline has passed and which are still 380 #: here — i.e. what only a stopped ``account_deletion`` job 381 #: produces. 382 #: 383 #: ⚠️ **Overdue, never *pending*.** Every scheduled deletion is 384 #: pending until its deadline, so a pending count is healthy by 385 #: construction and says nothing about whether the sweep runs. This 386 #: is the product-side reading ``registry_usage_scan`` records the 387 #: need for: *a job's self-report is not a health signal for what it 388 #: produces*, and that job reported ``ok`` hourly for the life of a 389 #: deployment over its own failure. 390 #: 391 #: ⚠️ Defaults ``0``, which is the quiet direction — and that is the 392 #: right one here only because the alarming direction is a *standing* 393 #: claim: a producer that forgot would otherwise post a permanent 394 #: "the job is broken" banner on every healthy deployment, which is 395 #: how an operator learns to ignore it. A real stall grows the number 396 #: every tick, so the miss costs latency rather than the signal. 397 deletions_overdue: int = 0
Single aggregate payload for the admin landing page.
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.
1566class AdminPatchSubmissionDetail(AdminPatchSubmissionInfo): 1567 """One submission with the code it carried. 1568 1569 This is what keeping the bytes was *for*: a team disputes a verdict, 1570 or a checker bug is found on day two, and someone has to read what 1571 was actually submitted. Without a reader the stored blobs bought 1572 nothing. 1573 """ 1574 1575 #: ``{path: base64(bytes)}``. Base64 rather than text because a 1576 #: patch target may legitimately be binary, and decoding it into a 1577 #: string would corrupt it into something that merely looks like a 1578 #: bad submission. 1579 files_b64: dict[str, str] = Field(default_factory=dict) 1580 #: ``{path: sha256}`` — an equality match across teams, which is 1581 #: what is left once uniqueness has stopped flag sharing but nothing 1582 #: stops *patch* sharing. 1583 digests: dict[str, str] = Field(default_factory=dict) 1584 #: The node's per-stage results for the latest verification, so a 1585 #: disputed `inconclusive` says *which* stage — the service never 1586 #: came up, the checker could not run, the exploit could not run — 1587 #: rather than one sentence for all three. Empty on a submission 1588 #: judged before this shipped, and on one the platform never judged. 1589 steps: list[PatchStepInfo] = Field(default_factory=list)
One submission with the code it carried.
This is what keeping the bytes was for: a team disputes a verdict, or a checker bug is found on day two, and someone has to read what was actually submitted. Without a reader the stored blobs bought nothing.
1529class AdminPatchSubmissionInfo(PatchSubmissionInfo): 1530 """One row of the organiser's defence queue. 1531 1532 The player's view plus the four things only an organiser asks: 1533 *whose* it is, and — when a verdict is disputed — which worker 1534 decided it after how many tries. 1535 1536 Still **without the code**, and here that is a stronger requirement 1537 than on the parent: this is a competition-wide listing, so shipping 1538 bodies would mean reading every stored patch of the event to render 1539 a table of statuses. The bytes live on 1540 :class:`AdminPatchSubmissionDetail`, one row at a time. 1541 """ 1542 1543 team_id: str = "" 1544 user_id: str = "" 1545 #: Which worker judged it, for correlating with that node's logs. 1546 node_id: str = "" 1547 attempts: int = 0
One row of the organiser's defence queue.
The player's view plus the four things only an organiser asks: whose it is, and — when a verdict is disputed — which worker decided it after how many tries.
Still without the code, and here that is a stronger requirement
than on the parent: this is a competition-wide listing, so shipping
bodies would mean reading every stored patch of the event to render
a table of statuses. The bytes live on
AdminPatchSubmissionDetail, one row at a time.
1592class AdminPatchSubmissionPage(CtfyModel): 1593 """A page of the defence queue, with the total behind it. 1594 1595 ``total`` is its own query rather than ``len(items)`` so a pager can 1596 say "1-20 of 340" — and, with a ``status`` filter, it is also the 1597 answer to "is the queue draining". 1598 """ 1599 1600 items: list[AdminPatchSubmissionInfo] = Field(default_factory=list) 1601 total: int = 0
A page of the defence queue, with the total behind it.
total is its own query rather than len(items) so a pager can
say "1-20 of 340" — and, with a status filter, it is also the
answer to "is the queue draining".
183class AdminRecentError(CtfyModel): 184 """An archived instance that ended in ``stop_reason="error"``.""" 185 186 instance_id: str 187 challenge_id: str = "" 188 team_id: str = "" 189 node_id: str = "" 190 error: str = "" 191 stopped_at: float = 0.0
An archived instance that ended in stop_reason="error".
202class AdminSilentChallenge(CtfyModel): 203 """A challenge that has launches and submissions but no full solves — 204 a strong signal that the challenge or its flag is broken.""" 205 206 challenge_id: str 207 name: str = "" 208 launches: int = 0 209 submissions: int = 0
A challenge that has launches and submissions but no full solves — a strong signal that the challenge or its flag is broken.
19class AdminSolveCell(CtfyModel): 20 """One cell in the team × challenge solve matrix. 21 22 Unsolved cells are emitted when the team has at least one submission 23 against the challenge (``attempts > 0``) so the UI can distinguish 24 "tried and failed" from "never attempted". 25 """ 26 27 team_id: str 28 challenge_id: str 29 solved: bool = False 30 solved_at: datetime | None = None 31 solve_time_s: float = 0.0 32 attempts: int = 0 33 # True when this team is the first (fastest) solver of the challenge. 34 first_blood: bool = False 35 # How many of the challenge's declared questions this team has 36 # captured in-window. ``solved`` is the special case where this 37 # equals the challenge's ``milestones``. 38 # 39 # ⚠️ **A partial capture is the whole point of this field.** Before 40 # it, a team 30 milestones into a 33-milestone range was 41 # indistinguishable from one that guessed wrong three times: the 42 # cell's only progress signal was ``solved``, and a range is 43 # fully solved almost never. Measured on the real route with a team 44 # holding 4 of 5 questions -- ``solved=False, attempts=4``, the same 45 # shape four wrong guesses produce. 46 # 47 # ⚠️ ``0`` here is a true zero (nothing captured), unlike 48 # ``AdminSolveMatrixChallenge.milestones``. 49 captured: int = 0 50 # When this team last took a milestone here. 51 # 52 # ⚠️ **Not a second copy of ``solved_at``**, though on a completed 53 # challenge the two carry the same instant. They answer different 54 # questions -- "when was this finished" (null while it is not) and 55 # "when did this team last make progress" (null only if it never 56 # has) -- and on a 33-milestone range the first is null all event 57 # while the second is the only thing moving. They cannot disagree: 58 # both are derived from the same captured-flag map, and a full 59 # solve's final capture *is* what completed it. 60 last_captured_at: datetime | None = None
One cell in the team × challenge solve matrix.
Unsolved cells are emitted when the team has at least one submission
against the challenge (attempts > 0) so the UI can distinguish
"tried and failed" from "never attempted".
92class AdminSolveMatrix(CtfyModel): 93 """Team × challenge solve matrix for the admin dashboard. 94 95 Rows (teams) and columns (challenges) pre-sorted — teams by solve count 96 desc, challenges by category → difficulty — so the frontend can render 97 the grid verbatim without re-sorting. Cells are sparse: only teams that 98 have at least attempted a challenge contribute a row there. 99 """ 100 101 teams: list[AdminSolveMatrixTeam] = Field(default_factory=list) 102 challenges: list[AdminSolveMatrixChallenge] = Field(default_factory=list) 103 cells: list[AdminSolveCell] = Field(default_factory=list)
Team × challenge solve matrix for the admin dashboard.
Rows (teams) and columns (challenges) pre-sorted — teams by solve count desc, challenges by category → difficulty — so the frontend can render the grid verbatim without re-sorting. Cells are sparse: only teams that have at least attempted a challenge contribute a row there.
71class AdminSolveMatrixChallenge(CtfyModel): 72 challenge_id: str 73 name: str = "" 74 category: str = "" 75 difficulty: str = "" 76 solves_count: int = 0 77 # How many questions the challenge declares -- the denominator for 78 # every cell's ``captured``. 79 # 80 # ⚠️ **On the column, never on the cell.** It is a property of the 81 # challenge, so putting it on all 6,600 cells would be one number 82 # copied per square with nothing keeping the copies equal. 83 # 84 # ⚠️ ``0`` means **not declared**, never "no milestones" -- a spec 85 # the reader could not resolve (a challenge retired after a solve) 86 # lands here. A reader must render no fraction at all rather than 87 # dividing by it; a `captured / 0` is what puts `NaN%` or `Infinity` 88 # on a wall in a room full of people. 89 milestones: int = 0
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
63class AdminSolveMatrixTeam(CtfyModel): 64 team_id: str 65 name: str = "" 66 solved: int = 0 67 #: See :attr:`ctfy.server.models.ScoreboardEntry.anonymous`. 68 anonymous: bool = False
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
171class AdminStuckInstance(CtfyModel): 172 """A live instance stuck in STARTING for too long — likely a node-side 173 failure that didn't reconcile to ERROR cleanly.""" 174 175 instance_id: str 176 challenge_id: str = "" 177 team_id: str = "" 178 node_id: str = "" 179 requested_at: float = 0.0 180 stuck_for_s: float = 0.0
A live instance stuck in STARTING for too long — likely a node-side failure that didn't reconcile to ERROR cleanly.
1047class AdminTaskInfo(CtfyModel): 1048 """One background task on ``GET /admin/tasks`` / detail. A projection 1049 of ``TaskState`` onto the wire (these routes are admin-only, so the 1050 full ``error_detail`` traceback tail is included).""" 1051 1052 id: str = "" 1053 kind: str = "" 1054 status: str = "" 1055 params: dict[str, Any] = Field(default_factory=dict) 1056 # ⚠️ Typed here and **not** on ``TaskState``. That one is a state 1057 # model feeding ``to_orm`` over a freeform JSON column whose old 1058 # rows predate every current handler; this one exists to reach a 1059 # client, which is where the type is worth anything — it is what 1060 # ``openapi-typescript`` carries into the browser, replacing a 1061 # cast and seven hand-written coercions in ``TaskResultDetails``. 1062 result: TaskResult = Field(default_factory=TaskResult) 1063 progress: float = 0.0 1064 progress_message: str = "" 1065 # Which step of *this handler's own plan* the run is inside, or ``""`` 1066 # for a handler with no plan — which is every one but the three the 1067 # range studio dispatches. 1068 # 1069 # ⚠️ **`str`, and deliberately not `VerificationStepKey`**, even 1070 # though that is the only producer today. The domain is per-handler: 1071 # a second planned handler brings its own key space, so narrowing 1072 # here would publish a promise this field cannot keep, and adding 1073 # ``""`` to the union would be a member meaning *unset* — the reading 1074 # `AdminTimeseries.metric` refuses. The completeness that matters is 1075 # already typed where it belongs, on ``GauntletBuildStep.key``; a 1076 # reader **looks this value up in the plan** rather than indexing a 1077 # palette with it, so an unrecognised step marks no row live instead 1078 # of throwing. 1079 progress_step: str = "" 1080 error: str = "" 1081 error_detail: str = "" 1082 cancel_requested: bool = False 1083 created_by_user_id: str = "" 1084 created_by_name: str = "" 1085 created_at_ts: float = 0.0 1086 started_at_ts: float = 0.0 1087 finished_at_ts: float = 0.0
One background task on GET /admin/tasks / detail. A projection
of TaskState onto the wire (these routes are admin-only, so the
full error_detail traceback tail is included).
1090class AdminTaskListResponse(CtfyModel): 1091 """Paginated task list.""" 1092 1093 items: list[AdminTaskInfo] = Field(default_factory=list) 1094 total: int = 0 1095 offset: int = 0 1096 limit: int = 50
Paginated task list.
1099class AdminTaskLogLine(CtfyModel): 1100 """One verbose log line for a task (admin-only).""" 1101 1102 seq: int = 0 1103 ts: float = 0.0 1104 level: str = "info" 1105 message: str = ""
One verbose log line for a task (admin-only).
1108class AdminTaskLogsResponse(CtfyModel): 1109 """A cursor page of task logs. ``next_after`` is the ``seq`` to pass as 1110 ``?after=`` on the next poll (the last item's seq, or the request's 1111 ``after`` when the page is empty — so an empty poll never rewinds the 1112 cursor).""" 1113 1114 items: list[AdminTaskLogLine] = Field(default_factory=list) 1115 next_after: int = 0
A cursor page of task logs. next_after is the seq to pass as
?after= on the next poll (the last item's seq, or the request's
after when the page is empty — so an empty poll never rewinds the
cursor).
1118class AdminTaskSubmitRequest(CtfyModel): 1119 """Body of ``POST /admin/tasks``. ``kind`` is validated against the 1120 runner's handler registry; ``params`` is the kind-specific input 1121 (e.g. ``{"competition_id": ...}`` or ``{"job": "ttl_sweep"}``).""" 1122 1123 kind: str = "" 1124 params: dict[str, Any] = Field(default_factory=dict)
386class AdminTeamRow(CtfyModel): 387 """One row of the organiser's team table. 388 389 Deliberately **not** ``TeamInfo`` plus a field. ``TeamInfo`` is the 390 public projection, and standing is not public: a disqualification 391 reaches the team and the organisers, and announcing it more widely is 392 the organiser's call — the same reasoning that keeps 393 ``team_disqualified`` off the public SSE feed. Putting 394 ``disqualified`` on the public model would publish the verdict on 395 every team page, from a route nobody had to think about. 396 397 Also **not** ``RegistrationTeamRow``: that one carries every member's 398 sealed personal details and lands an audit row for the bulk read. 399 Managing a roster does not need anybody's phone number, and paying 400 that price to render a table would make the audit trail meaningless 401 by filling it with routine work. 402 """ 403 404 id: str 405 name: str 406 description: str = "" 407 competition_id: str = "" 408 captain_user_id: str = "" 409 captain_display_name: str = "" 410 member_count: int = 0 411 created_at: datetime | None = None 412 review_status: str = "" 413 disqualified: bool = False 414 disqualified_at: datetime | None = None 415 #: Who took this team out, as a name rather than an id. 416 #: 417 #: ⚠️ **Resolved server-side**, the repair ``AdminHarnessRow`` 418 #: records: a client mapping ids to names needs a read per row and 419 #: eventually disagrees with the server about a deleted account. 420 #: 421 #: ⚠️ **Two different blanks, and neither is the platform.** A row 422 #: disqualified before the column existed names nobody; an id 423 #: nothing resolves is an account that has gone. Both read as 424 #: *unattributed* here — the activity row keeps the id for whoever 425 #: needs it — because inventing an author is worse than an empty 426 #: cell, and ``actor_id=""`` already means an unattributable actor 427 #: everywhere else in this codebase. 428 disqualified_by_display_name: str = "" 429 disqualification_reason: str = "" 430 members: list[TeamMemberInfo] = Field(default_factory=list)
One row of the organiser's team table.
Deliberately not TeamInfo plus a field. TeamInfo is the
public projection, and standing is not public: a disqualification
reaches the team and the organisers, and announcing it more widely is
the organiser's call — the same reasoning that keeps
team_disqualified off the public SSE feed. Putting
disqualified on the public model would publish the verdict on
every team page, from a route nobody had to think about.
Also not RegistrationTeamRow: that one carries every member's
sealed personal details and lands an audit row for the bulk read.
Managing a roster does not need anybody's phone number, and paying
that price to render a table would make the audit trail meaningless
by filling it with routine work.
296class AdminTimeseries(CtfyModel): 297 """24h-by-default bucketed counts driving the admin Overview pulse charts.""" 298 299 #: ⚠️ **Required, with no default.** Every other string here 300 #: defaults to ``""``, and adding ``""`` to the union to keep that 301 #: habit would hand the client a seventh member meaning *unset* — 302 #: for a field the one producer always writes from a validated 303 #: query parameter. A default would be a new claim; a required 304 #: field makes a producer that forgets fail loudly instead. 305 metric: AdminTimeseriesMetric 306 window_s: int = 0 307 bucket_s: int = 0 308 buckets: list[AdminTimeseriesBucket] = Field(default_factory=list)
24h-by-default bucketed counts driving the admin Overview pulse charts.
244class AdminTimeseriesBucket(CtfyModel): 245 """One bucket of an admin time-series chart. 246 247 ``ts`` is the right edge of the bucket in Unix seconds — the natural 248 point to evaluate "active right now" metrics like running_instances — 249 and the natural label to render at the right of the bucket on the 250 frontend. 251 252 ``partial=True`` marks the rightmost bucket whose right edge is 253 ``now``; it represents an in-progress window that hasn't finished 254 accumulating yet. 255 256 ⚠️ **This sentence used to describe a renderer that did not exist** 257 ("semi-transparent fill, pulsing label"), and could not: the 258 hand-written copy of this model in ``frontend/src/api.ts`` had 259 dropped the field, so nothing could read it and ``tsc`` could not 260 report the attempt. What the charts do now is plot the **finished** 261 windows and say *live* beside the headline while one is filling — 262 an in-progress count drawn at the weight of a whole window is a dip 263 at the right edge of every chart that is only the clock. 264 """ 265 266 ts: float = 0.0 267 value: int = 0 268 partial: bool = False
One bucket of an admin time-series chart.
ts is the right edge of the bucket in Unix seconds — the natural
point to evaluate "active right now" metrics like running_instances —
and the natural label to render at the right of the bucket on the
frontend.
partial=True marks the rightmost bucket whose right edge is
now; it represents an in-progress window that hasn't finished
accumulating yet.
⚠️ This sentence used to describe a renderer that did not exist
("semi-transparent fill, pulsing label"), and could not: the
hand-written copy of this model in frontend/src/api.ts had
dropped the field, so nothing could read it and tsc could not
report the attempt. What the charts do now is plot the finished
windows and say live beside the headline while one is filling —
an in-progress count drawn at the weight of a whole window is a dip
at the right edge of every chart that is only the clock.
587class AdminTokenRevokeResponse(CtfyModel): 588 """Result of ``POST /admin/tokens/{token_id}/revoke``. 589 590 The revoke half of the trace surface: ``token trace`` resolves a 591 leaked ``pf_…`` to its ``token_id`` (marking the *live* one in a 592 rotation lineage), and this ends that row. Owner-revoke is 593 account-scoped by design, so an operator holding a leaked secret — 594 especially one minting synthetic **agent** entrants, whose owner is 595 a user nobody can log in as — had no way to end it short of a direct 596 DB write. This is that door. 597 598 ⚠️ **A miss here is a 404, unlike ``token-trace``'s 200.** The trace 599 route takes a *secret* and "not one we minted" is the reassuring 600 answer; this route takes a *``token_id``* the operator already read 601 off a trace, so an id that resolves to nothing is a wrong id, and a 602 404 says exactly that. 603 604 ⚠️ **Revocation is idempotent, not an error.** Re-revoking a row 605 that is already revoked returns ``already_revoked=True`` at 200 with 606 its original ``revoked_at`` — the compare-and-set in the backend is 607 what makes a second caller safe, and the audit event fires only on 608 the transition. 609 """ 610 611 token_id: str 612 owner_user_id: str = "" 613 owner_display_name: str = "" 614 kind: TokenKind 615 label: str = "" 616 already_revoked: bool = False 617 revoked_at: datetime | None = None
Result of POST /admin/tokens/{token_id}/revoke.
The revoke half of the trace surface: token trace resolves a
leaked pf_… to its token_id (marking the live one in a
rotation lineage), and this ends that row. Owner-revoke is
account-scoped by design, so an operator holding a leaked secret —
especially one minting synthetic agent entrants, whose owner is
a user nobody can log in as — had no way to end it short of a direct
DB write. This is that door.
⚠️ A miss here is a 404, unlike token-trace's 200. The trace
route takes a secret and "not one we minted" is the reassuring
answer; this route takes a token_id the operator already read
off a trace, so an id that resolves to nothing is a wrong id, and a
404 says exactly that.
⚠️ Revocation is idempotent, not an error. Re-revoking a row
that is already revoked returns already_revoked=True at 200 with
its original revoked_at — the compare-and-set in the backend is
what makes a second caller safe, and the audit event fires only on
the transition.
415class AdminTrafficInstanceRow(CtfyModel): 416 """One instance × team × challenge row for the admin traffic dashboard.""" 417 418 instance_id: str 419 team_id: str 420 team_name: str = "" 421 challenge_id: str = "" 422 name: str = "" 423 status: str = "" 424 request_count: int = 0 425 started_at: float = 0.0 426 stopped_at: float = 0.0 427 # ``True`` if the instance is still running (request_count is approximate 428 # — refreshed only when the dashboard re-fetches). 429 live: bool = False
One instance × team × challenge row for the admin traffic dashboard.
432class AdminTrafficSummary(CtfyModel): 433 """Top-level response for ``GET /admin/traffic/summary``. 434 435 ``teams`` and ``total_requests`` are sums over **every** archived 436 instance; ``instances`` lists only the newest archived ones (plus the 437 live boxes), because a row per instance ever run is a payload that 438 grows without bound. 439 440 ⚠️ **All three used to be computed from that same newest page**, under 441 a module docstring saying "every archived instance": a team whose 442 traffic was older had none, and ``total_requests`` stopped growing at 443 the cap. The totals are summed in SQL now, which is what makes the 444 list a *page* of something larger — so the response says how large. 445 """ 446 447 teams: list[AdminTrafficTeamRow] = Field(default_factory=list) 448 instances: list[AdminTrafficInstanceRow] = Field(default_factory=list) 449 total_requests: int = 0 450 #: Every archived instance with a team plus every live one — what 451 #: ``instances`` is the newest part of, and what the page's instance 452 #: count shows. ``len(instances)`` is not a count of anything. 453 #: 454 #: ⚠️ Defaults ``0`` so the field set stays additive, as 455 #: ``AdminOverview``'s counters do; a server that predates the field 456 #: sends none, so a reader released separately from the server (the 457 #: CLI) takes ``max(instances_total, len(instances))`` rather than 458 #: reporting zero beside a list of rows. 459 instances_total: int = 0
Top-level response for GET /admin/traffic/summary.
teams and total_requests are sums over every archived
instance; instances lists only the newest archived ones (plus the
live boxes), because a row per instance ever run is a payload that
grows without bound.
⚠️ All three used to be computed from that same newest page, under
a module docstring saying "every archived instance": a team whose
traffic was older had none, and total_requests stopped growing at
the cap. The totals are summed in SQL now, which is what makes the
list a page of something larger — so the response says how large.
400class AdminTrafficTeamRow(CtfyModel): 401 """Per-team aggregate row for the admin traffic dashboard. 402 403 Sums HTTP request counts across every archived instance the team has 404 ever launched, plus the live count for any instances the team is 405 still running. 406 """ 407 408 team_id: str 409 team_name: str = "" 410 instance_count: int = 0 411 request_count: int = 0 412 last_activity_at: float = 0.0
Per-team aggregate row for the admin traffic dashboard.
Sums HTTP request counts across every archived instance the team has ever launched, plus the live count for any instances the team is still running.
194class AdminUnhealthyNode(CtfyModel): 195 node_id: str 196 display_name: str = "" 197 url: str = "" 198 last_heartbeat_ts: float = 0.0 199 downtime_s: float = 0.0
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
179class AdminUserInfo(UserInfo): 180 """Admin-only view of a user. Carries the privilege tier and the 181 most recent role-change audit fields.""" 182 183 email: str = "" 184 role: UserRole = "user" 185 promoted_by: str | None = None 186 promoted_at: datetime | None = None 187 #: What to call the account in ``promoted_by``. Resolved in bulk at 188 #: the route by `ctfy.server.entity_labels.operator_labels` — the 189 #: operator's answer, so it ends at the address when there is no 190 #: display name, because the reader is asking *who granted this* 191 #: and an address is how they go and ask about it. 192 #: 193 #: ⚠️ **Empty is never the id**, the contract 194 #: `InstanceInfo.team_name` states: the id is on the wire beside 195 #: this, so echoing it would make "the account is gone" render 196 #: identically to a resolved row and leave the client with no way 197 #: to tell that it should fall back. 198 promoted_by_display_name: str = "" 199 # OAuth providers the user has bound (e.g. ``["github", "google"]``), 200 # so the admin user list can surface which SSO accounts are linked. 201 providers: list[str] = Field(default_factory=list) 202 #: When this account was taken off public standings; ``None`` = it 203 #: counts. Here so the admin list can show which accounts are the 204 #: deployment's own without a second read. 205 standings_excluded_at: datetime | None = None 206 #: When this account's self-requested deletion becomes final; 207 #: ``None`` = it never asked. `DELETE /me` stamps it and revokes 208 #: every token, so a pending account is *locked* — which on this 209 #: page is otherwise indistinguishable from one that simply stopped 210 #: playing. Here for ``standings_excluded_at``'s reason: the admin 211 #: list can show it without a second read. 212 #: 213 #: ⚠️ **The instant, never a ``pending: bool``.** An operator 214 #: arrives with two questions — *is this pending* and *when does it 215 #: go* — and the instant answers both where a flag answers one; a 216 #: flag beside it would be two claims about one fact, free to 217 #: disagree. 218 deletion_effective_at: datetime | None = None 219 #: Whether this account has anything outstanding to prove — composed 220 #: server-side from :func:`~ctfy.server.email_verification.user_is_verified`, 221 #: the predicate the write gate itself reads. 222 #: 223 #: ⚠️ **Not ``email_verified_at is not None``**, and the difference is 224 #: a whole population rather than an edge case: an account with no 225 #: address is *outside* the question, not unproved, and three kinds 226 #: of them sit in this very table (every synthetic agent entrant, the 227 #: platform's own service account, and a human on a generic-OIDC 228 #: deployment configured with ``oidc_trust_email_verified=false``). 229 #: The raw column would badge all of them as blocked while the server 230 #: lets every one of their writes through. 231 #: 232 #: ⚠️ Defaults **False**, the loud direction: this model has two 233 #: producers, and a producer that forgets makes every row on the page 234 #: claim it is blocked — visible in a minute — rather than reporting 235 #: a frozen account as fine. The *client* reads ``=== false``, so an 236 #: older server that sends neither field badges nobody. 237 email_verified: bool = False 238 #: When proof of the address first existed, or ``None``. 239 #: 240 #: ⚠️ **Beside the flag, not instead of it**, because ``None`` is two 241 #: different facts and only the pair separates them: with 242 #: ``email_verified`` false it means *never proved* (the actionable 243 #: state), and with it true it means *there is no address to prove*. 244 #: A reader with one field can render two states out of three. 245 email_verified_at: datetime | None = None 246 #: Who vouched for the address, when the account did not prove it 247 #: itself — the id of the super-admin who used 248 #: ``POST /admin/users/{user_id}/verify-email``. ``""`` is the 249 #: account's own proof. 250 #: 251 #: ⚠️ **Read with the two fields above, never alone.** ``""`` means 252 #: *proved by the account* only where ``email_verified`` is true and 253 #: ``email_verified_at`` is set; on an unproved account it says 254 #: nothing. A reader that branches on this one column answers "the 255 #: account proved it" about an account that has proved nothing. 256 #: 257 #: ⚠️ Published so the table can say *vouched* rather than 258 #: *verified*. Without it an operator's assertion renders 259 #: identically to a mailed code, which is the whole objection the 260 #: column exists to answer — a distinction stored and then withheld 261 #: from the one page that would show it is a distinction nobody has. 262 email_verified_by: str = "" 263 #: What to call the account in ``email_verified_by``. Same resolver 264 #: and the same three states as ``promoted_by_display_name``. 265 #: 266 #: ⚠️ The pair is what makes the column readable, not the name 267 #: alone: a blank here **beside a set** ``email_verified_by`` is an 268 #: operator whose account has since gone, which is a different 269 #: sentence from *nobody vouched* (a blank ``email_verified_by``) 270 #: and the one an audit is actually asking about. 271 email_verified_by_display_name: str = ""
Admin-only view of a user. Carries the privilege tier and the most recent role-change audit fields.
145class AffiliationInfo(CtfyModel): 146 """The institution one account publishes, as a stranger reads it. 147 148 Three fields, and what is **absent** is most of the point. 149 150 ⚠️ **The address never travels.** ``users.affiliation_email`` names 151 which of the account's proved addresses the badge derives from and 152 that choice is the person's own business; a ``.edu`` address is 153 often a student number, and the original ask — show the address 154 beside the logo — is a different and much larger disclosure than the 155 one that was agreed. `test_the_badge_publishes_no_address` is the 156 pin, because a field added to this model later would leak it in 157 silence and every surface would go on rendering correctly. 158 159 ⚠️ **No ``verified_at``, and the reason is that the column does not 160 mean what a date on a badge would say.** ``user_emails.verified_at`` 161 fills and never overwrites (`rank_solve`'s rule), so it is when 162 proof *first* existed rather than when it was last checked — 163 publishing it as *"verified September 2019"* invites a reader to 164 conclude *stale*, which is a claim this platform cannot make: what 165 it knows is that the address is attached **now** and was proved 166 once, and the badge's own sentence already carries the first half. 167 A field that supports a wrong reading is worse than an absent one. 168 169 ⚠️ **No ``kind`` and no ``url``.** Both are on the catalogue row and 170 neither has a reader here: the chip draws a logo keyed on ``domain`` 171 or a monogram from ``short_name``, and its title names the 172 institution. A published field with no reader is the recorded shape 173 (`EvalRunState.transcript_path`), so they arrive with whatever 174 renders them. 175 176 ⚠️ **``names`` crosses as a map**, resolved in the browser, for 177 `InstitutionInfo`'s reason — the language is a client-side 178 preference switched without a reload. 179 """ 180 181 #: The catalogue key. The **institution's** domain, never the 182 #: account's address: it is what a logo file is named after 183 #: (``frontend/public/institutions/<domain>.svg``) and it identifies 184 #: a public organisation rather than a person. 185 domain: str = "" 186 names: dict[str, str] = Field(default_factory=dict) 187 #: What a monogram chip draws when the repository ships no logo. 188 short_name: str = ""
The institution one account publishes, as a stranger reads it.
Three fields, and what is absent is most of the point.
⚠️ The address never travels. users.affiliation_email names
which of the account's proved addresses the badge derives from and
that choice is the person's own business; a .edu address is
often a student number, and the original ask — show the address
beside the logo — is a different and much larger disclosure than the
one that was agreed. test_the_badge_publishes_no_address is the
pin, because a field added to this model later would leak it in
silence and every surface would go on rendering correctly.
⚠️ No verified_at, and the reason is that the column does not
mean what a date on a badge would say. user_emails.verified_at
fills and never overwrites (rank_solve's rule), so it is when
proof first existed rather than when it was last checked —
publishing it as "verified September 2019" invites a reader to
conclude stale, which is a claim this platform cannot make: what
it knows is that the address is attached now and was proved
once, and the badge's own sentence already carries the first half.
A field that supports a wrong reading is worse than an absent one.
⚠️ No kind and no url. Both are on the catalogue row and
neither has a reader here: the chip draws a logo keyed on domain
or a monogram from short_name, and its title names the
institution. A published field with no reader is the recorded shape
(EvalRunState.transcript_path), so they arrive with whatever
renders them.
⚠️ names crosses as a map, resolved in the browser, for
InstitutionInfo's reason — the language is a client-side
preference switched without a reload.
191class AffiliationSelection(CtfyModel): 192 """The account's own view of its badge: the choice and its effect. 193 194 ⚠️ **Both halves, because either alone is ambiguous.** ``email`` 195 carries the address chosen and ``affiliation`` what strangers 196 actually see, and the interesting state is the one where they 197 disagree: an address that was picked and has since lost its proof, 198 or sits at a domain the operator has not named, reads as *chosen, 199 showing nothing* — which a card holding only ``affiliation`` cannot 200 tell from *nothing chosen*, and the two have opposite repairs 201 (prove it / ask the operator to name the domain, against pick one). 202 203 ⚠️ **The address is here and never on :class:`AffiliationInfo`.** 204 This model answers to the account about itself; that one is what a 205 stranger reads. 206 """ 207 208 #: The chosen address, verbatim as the account typed it. ``""`` is 209 #: *no badge*, which is the default and the opt-out. 210 email: str = "" 211 #: What the badge resolves to right now, or ``None``. 212 affiliation: AffiliationInfo | None = None
The account's own view of its badge: the choice and its effect.
⚠️ Both halves, because either alone is ambiguous. email
carries the address chosen and affiliation what strangers
actually see, and the interesting state is the one where they
disagree: an address that was picked and has since lost its proof,
or sits at a domain the operator has not named, reads as chosen,
showing nothing — which a card holding only affiliation cannot
tell from nothing chosen, and the two have opposite repairs
(prove it / ask the operator to name the domain, against pick one).
⚠️ The address is here and never on AffiliationInfo.
This model answers to the account about itself; that one is what a
stranger reads.
1342class AgentActionRow(CtfyModel): 1343 """One published entrant's current action, as a room reads it. 1344 1345 ⚠️ **One line, scrubbed and cut, never the conversation.** 1346 ``ctfy.core.agent_action`` picks the newest tool call (or, failing 1347 that, the newest thing the model said), runs it through 1348 ``ctfy.core.redaction`` and caps it at ``MAX_ACTION_CHARS``. The 1349 three rules ``AgentPulseBeat`` states still hold — what changed is 1350 that two of them are answered by the scrub and the third by the 1351 author's own ``TeamState.wall_published_at``. 1352 1353 ⚠️ **An author who never opted in has no row here at all**, rather 1354 than a row with a blank line: a blank is what a published entrant 1355 with nothing to show looks like, and a wall that rendered both the 1356 same would be publishing the *existence* of a decision nobody made. 1357 """ 1358 1359 team_id: str = "" 1360 #: ⚠️ **Projected**, like every other public surface naming a squad; 1361 #: ``""`` beside a set id is ``InstanceInfo.team_name``'s third 1362 #: state and never the id as a fallback. 1363 team_name: str = "" 1364 anonymous: bool = False 1365 #: Which model wrote this line. ⚠️ Cleared for a withheld entrant 1366 #: for ``anonymise_awd_board``'s reason — the attribution is 1367 #: identity, so a row that hid the name and kept it would be 1368 #: decorative. 1369 model: str = "" 1370 #: ⚠️ **Required, no default.** ``none`` is a real answer (the turn 1371 #: produced nothing a room can read), so a fourth value meaning 1372 #: *this server did not say* would be a state with no reader — 1373 #: ``AgentPulseBeat.trouble``'s rule. 1374 kind: AgentActionKind 1375 #: The scrubbed line, already cut. ⚠️ Plain text on the wire and 1376 #: plain text on the wall: ``LlmCallBody``'s rule for prose an 1377 #: untrusted image wrote, and a projector is the worst place to 1378 #: relax it. 1379 line: str = "" 1380 #: When the call this was read from **finished** — 1381 #: ``AgentPulseBeat.at_ts``'s floor, repeated because it is the 1382 #: only instant there is. 1383 at_ts: float = 0.0 1384 #: Was there a transcript to read at all? ⚠️ **Separate from an 1385 #: empty ``line``**: retention reclaims a body and leaves the row, 1386 #: so *the agent said nothing* and *the transcript is gone* are 1387 #: opposite facts that render identically at three metres, and this 1388 #: is the only field that can tell them apart. 1389 available: bool = False 1390 #: Whether this entrant's **full conversation** is publicly linkable 1391 #: (B2): the author gave the second consent and the entrant is not 1392 #: anonymous. ⚠️ Not the same as being on the wall — an author may 1393 #: put a line here and withhold the transcript, so the landing feed 1394 #: shows the click-through only when this is set, and a "not 1395 #: published" note otherwise. Defaults ``False``: an older server, or 1396 #: an author who only opted into the line, offers no transcript. 1397 transcript_public: bool = False 1398 #: The call whose line this row shows, so the feed can link straight 1399 #: to its full conversation. ⚠️ **Set only when ``transcript_public``** 1400 #: — the row carries a linkable id exactly when the feed renders a 1401 #: link, and ``""`` for a withheld, anonymous or un-opted-in entrant, 1402 #: so ``anonymise_agent_actions`` has nothing extra to blank. The 1403 #: transcript route re-checks every gate, so a leaked id still meets 1404 #: ``transcript_not_published`` — this only spares the honest reader a 1405 #: dead click. 1406 call_id: str = ""
One published entrant's current action, as a room reads it.
⚠️ One line, scrubbed and cut, never the conversation.
ctfy.core.agent_action picks the newest tool call (or, failing
that, the newest thing the model said), runs it through
ctfy.core.redaction and caps it at MAX_ACTION_CHARS. The
three rules AgentPulseBeat states still hold — what changed is
that two of them are answered by the scrub and the third by the
author's own TeamState.wall_published_at.
⚠️ An author who never opted in has no row here at all, rather than a row with a blank line: a blank is what a published entrant with nothing to show looks like, and a wall that rendered both the same would be publishing the existence of a decision nobody made.
1409class AgentActionsPage(CtfyModel): 1410 """What this event's published agents are doing. 1411 1412 ⚠️ **Four empty states, and each field exists to separate two of 1413 them.** An empty ``rows`` means one of: this deployment keeps no 1414 transcript index (``indexing``), nobody has entered an agent 1415 (``entrants``), nobody's author opted in (``published``), or 1416 everybody opted in and no call has finished yet (all three set, 1417 ``rows`` empty). Collapsing any pair sends the room — or the 1418 organiser they ask — to look at the wrong thing. 1419 """ 1420 1421 competition_id: str = "" 1422 #: ``LlmCallPage.indexing``'s reason: with no gateway there is no 1423 #: index at all, so an empty page means the opposite of "nobody ran 1424 #: an agent". Rendered before the rows by the one reader there is. 1425 indexing: bool = True 1426 #: Agent entrants in this event, opted in or not. ⚠️ The 1427 #: denominator, so ``published`` is readable as a *share* rather 1428 #: than as a bare count — and so "nobody entered" cannot be 1429 #: mistaken for "nobody agreed". 1430 entrants: int = 0 1431 #: How many of those authors said yes. ⚠️ Published even when it 1432 #: equals ``entrants``: a room that cannot tell a full opt-in from 1433 #: a coincidence is being told something the page does not know. 1434 published: int = 0 1435 rows: list[AgentActionRow] = Field(default_factory=list) 1436 #: ⚠️ A route that reads at most ``agent_actions.MAX_ROWS`` 1437 #: entrants owes the number it could have drawn, or a complete 1438 #: answer and a truncated one read identically — the rule 1439 #: ``test_a_list_route_says_how_many`` pins one surface along. 1440 #: This is ``published``'s ceiling, not the page's. 1441 max_rows: int = 0
What this event's published agents are doing.
⚠️ Four empty states, and each field exists to separate two of
them. An empty rows means one of: this deployment keeps no
transcript index (indexing), nobody has entered an agent
(entrants), nobody's author opted in (published), or
everybody opted in and no call has finished yet (all three set,
rows empty). Collapsing any pair sends the room — or the
organiser they ask — to look at the wrong thing.
1061class AgentEntrantInfo(CtfyModel): 1062 """Whose agent an entry is, for a reader of the leaderboard. 1063 1064 ``entrant_kind`` says a row is not a person; this says *which* agent 1065 and *whose*. Two rows both reading "agent" with nothing behind them 1066 is a ranking nobody can check — and this is the surface where the 1067 checking happens, because a model result nobody can attribute or 1068 reproduce is a claim rather than a measurement. 1069 1070 ⚠️ **Absent for the platform's own eval entrant, not just for 1071 people.** Those harness rows have no owner, so there is no author to 1072 link to and no repository to open; offering one would point a reader 1073 at nobody. The board still marks the row as an agent — the two facts 1074 are separate. 1075 1076 ⚠️ **The image reference is deliberately not here.** It names a 1077 registry path somebody chose privately; ``source_url`` is what its 1078 author opted into publishing, and the digest is what makes a run 1079 reproducible without disclosing where the bytes live. 1080 """ 1081 1082 # ⚠️ ``id`` for the entity's own id, ``owner_user_id`` for the 1083 # foreign key — the wire-naming rule, and the frontend reads both 1084 # verbatim (one as a label, one as a link target). 1085 id: str 1086 display_name: str = "" 1087 # What the author called this build. A tag, so a mutable pointer. 1088 version: str = "" 1089 owner_user_id: str = "" 1090 owner_display_name: str = "" 1091 # ⚠️ ``""`` means *not published*, never "closed source": the board 1092 # renders no link and makes no claim about the agent. 1093 source_url: str = "" 1094 # ⚠️ **What actually ran**, as opposed to what it was called. An 1095 # operator approves a digest and the node pulls that digest, so on a 1096 # leaderboard this is the field that makes a result reproducible — 1097 # ``version`` is only what its author typed. 1098 image_digest: str = ""
Whose agent an entry is, for a reader of the leaderboard.
entrant_kind says a row is not a person; this says which agent
and whose. Two rows both reading "agent" with nothing behind them
is a ranking nobody can check — and this is the surface where the
checking happens, because a model result nobody can attribute or
reproduce is a claim rather than a measurement.
⚠️ Absent for the platform's own eval entrant, not just for people. Those harness rows have no owner, so there is no author to link to and no repository to open; offering one would point a reader at nobody. The board still marks the row as an agent — the two facts are separate.
⚠️ The image reference is deliberately not here. It names a
registry path somebody chose privately; source_url is what its
author opted into publishing, and the digest is what makes a run
reproducible without disclosing where the bytes live.
1230class AgentPulseBeat(CtfyModel): 1231 """One **finished** model call, as a projector reads it. 1232 1233 ⚠️ **No content, and that is three independent rules rather than a 1234 preference.** `LlmCallBody` is attacker-authored text; a relayed 1235 conversation carries the challenge's minted answer, which on an AWD 1236 gamebox is the current round's flag; and the body belongs to its 1237 author rather than to their squad, while a wall belongs to the room. 1238 So the metadata *is* the content here. 1239 1240 ⚠️ **No money and no key id.** `LlmCallRow` carries both for an 1241 operator; a public page showing what an entrant's agent costs 1242 discloses the operator's rates and the entrant's budget, and neither 1243 is what a room came to see. 1244 """ 1245 1246 #: When the call **finished**. ⚠️ There is no other instant to give: 1247 #: the gateway composes a record once, at completion, so no cadence 1248 #: anywhere can place a conversation in flight — `run_llm_call_drain` 1249 #: states that floor and the surfaces say it out loud rather than 1250 #: implying otherwise by updating quickly. 1251 at_ts: float = 0.0 1252 #: What the model wrote, which is the beat's weight on the wall. 1253 output_tokens: int = 0 1254 #: ⚠️ **Rides along so a wall can tell "0 tokens" from "nobody 1255 #: knows"** — ``extract_usage`` preserves that distinction on purpose 1256 #: and a chart drawing both as a flat tick destroys it at the last 1257 #: metre. ``LlmCallRow.usage_known``'s reason, one surface along. 1258 usage_known: bool = False 1259 upstream_ms: int = 0 1260 #: ⚠️ **Required, no default.** The members are different things to 1261 #: go and look at, so a sixth value meaning *this server did not 1262 #: say* would be a state with no reader and no remedy — 1263 #: ``QuestionCoverageRow.state``'s rule. One producer writes this and 1264 #: it always knows. 1265 trouble: PulseTrouble
One finished model call, as a projector reads it.
⚠️ No content, and that is three independent rules rather than a
preference. LlmCallBody is attacker-authored text; a relayed
conversation carries the challenge's minted answer, which on an AWD
gamebox is the current round's flag; and the body belongs to its
author rather than to their squad, while a wall belongs to the room.
So the metadata is the content here.
⚠️ No money and no key id. LlmCallRow carries both for an
operator; a public page showing what an entrant's agent costs
discloses the operator's rates and the entrant's budget, and neither
is what a room came to see.
1268class AgentPulseLane(CtfyModel): 1269 """One entrant's stream of beats, plus whether they are still going. 1270 1271 ⚠️ **The lane exists for a team with a running agent and no beats 1272 yet**, which is why this is a lane list rather than a flat beat list 1273 a client groups. An agent that has started and not yet finished a 1274 call is a real state and the one a room asks about first; derived 1275 from the beats alone it is indistinguishable from an entrant who 1276 never entered. 1277 """ 1278 1279 team_id: str = "" 1280 #: ⚠️ **Projected**, like every other public surface naming a squad. 1281 #: ``""`` beside a set id is the third state ``InstanceInfo.team_name`` 1282 #: records — never the id as a fallback. 1283 team_name: str = "" 1284 anonymous: bool = False 1285 #: Which model this entrant's agent is driving. ⚠️ Cleared for a 1286 #: withheld entrant for ``anonymise_awd_board``'s reason: the 1287 #: attribution is identity, so a lane that hid the name and kept it 1288 #: would be decorative. 1289 model: str = "" 1290 #: Is one of this entrant's runs still going? ⚠️ **The whole reason a 1291 #: quiet lane is readable.** Without it, "the agent is thinking", 1292 #: "the agent is finished" and "the drain is broken" are the same gap 1293 #: on the right-hand edge, and they are three different things to do. 1294 working: bool = False 1295 beats: list[AgentPulseBeat] = Field(default_factory=list)
One entrant's stream of beats, plus whether they are still going.
⚠️ The lane exists for a team with a running agent and no beats yet, which is why this is a lane list rather than a flat beat list a client groups. An agent that has started and not yet finished a call is a real state and the one a room asks about first; derived from the beats alone it is indistinguishable from an entrant who never entered.
1298class AgentPulsePage(CtfyModel): 1299 """What a competition's agents have been asking a model. 1300 1301 ⚠️ **``indexing`` rides along for ``LlmCallPage``'s reason**: an empty 1302 page means *either* nothing was relayed *or* this deployment keeps no 1303 index, and those call for opposite actions. Rendered before the lanes 1304 by the one reader there is. 1305 """ 1306 1307 competition_id: str = "" 1308 indexing: bool = True 1309 lanes: list[AgentPulseLane] = Field(default_factory=list) 1310 #: Every matching call, against the windowed beats above — a route 1311 #: that takes a ``limit`` owes one, or a complete answer and a 1312 #: truncated one read identically. 1313 total: int = 0
What a competition's agents have been asking a model.
⚠️ indexing rides along for LlmCallPage's reason: an empty
page means either nothing was relayed or this deployment keeps no
index, and those call for opposite actions. Rendered before the lanes
by the one reader there is.
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".
329class AnswerTraceHit(CtfyModel): 330 """One instance that was minted the traced answer. 331 332 ``live`` distinguishes a still-running instance (matched against the 333 in-memory minted map) from an archived one (matched against the 334 persisted digest). Both are authoritative; the flag only says 335 whether the instance can still be inspected or stopped. 336 """ 337 338 instance_id: str = "" 339 question_id: str = "" 340 team_id: str = "" 341 team_name: str = "" 342 challenge_id: str = "" 343 competition_id: str = "" 344 live: bool = False 345 started_at: float = 0.0 346 stopped_at: float = 0.0
One instance that was minted the traced answer.
live distinguishes a still-running instance (matched against the
in-memory minted map) from an archived one (matched against the
persisted digest). Both are authoritative; the flag only says
whether the instance can still be inspected or stopped.
315class AnswerTraceRequest(CtfyModel): 316 """Body for ``POST /admin/answer-trace``. 317 318 The answer travels in the body rather than a query parameter 319 because a live flag in a URL lands in every access log, proxy 320 cache, and browser history between the operator and the app. 321 322 Accepted in either the ``FLAG{...}`` or bare form — the server 323 normalises both to one digest, matching how submissions grade. 324 """ 325 326 answer: str = Field(min_length=1, max_length=512)
Body for POST /admin/answer-trace.
The answer travels in the body rather than a query parameter because a live flag in a URL lands in every access log, proxy cache, and browser history between the operator and the app.
Accepted in either the FLAG{...} or bare form — the server
normalises both to one digest, matching how submissions grade.
389class AnswerTraceResponse(CtfyModel): 390 """Result of ``POST /admin/answer-trace``. 391 392 ``hits`` is normally empty or length 1. More than one means two 393 instances genuinely drew the same value — astronomically unlikely 394 for a 128-bit token, but reported rather than silently collapsed 395 because attributing a leak to the wrong team is worse than 396 reporting ambiguity. 397 398 ``traceable_records`` is the number of archived records that carry 399 a digest at all. Instances archived before answer fingerprinting 400 shipped have none, so an empty ``hits`` on a low count means "this 401 predates tracing", not "this flag was never minted here" — the 402 distinction matters when an organiser is deciding whether to act. 403 """ 404 405 answer_hash: str = "" 406 hits: list[AnswerTraceHit] = Field(default_factory=list) 407 searched_live: int = 0 408 searched_records: int = 0 409 traceable_records: int = 0
Result of POST /admin/answer-trace.
hits is normally empty or length 1. More than one means two
instances genuinely drew the same value — astronomically unlikely
for a 128-bit token, but reported rather than silently collapsed
because attributing a leak to the wrong team is worse than
reporting ambiguity.
traceable_records is the number of archived records that carry
a digest at all. Instances archived before answer fingerprinting
shipped have none, so an empty hits on a low count means "this
predates tracing", not "this flag was never minted here" — the
distinction matters when an organiser is deciding whether to act.
251class ArchiveStoreReport(CtfyModel): 252 """The platform's instance-archive store and its last retention pass (#1714). 253 254 Held in process memory by ``run_instance_archive_retention`` and read 255 by ``GET /admin/instance-storage``: absent until the first pass after 256 a restart, which the page shows as *not measured yet*. 257 """ 258 259 measured_at: float = 0.0 260 status: PassStatus = PassStatus.OK 261 root: str = "" 262 instances: int = 0 263 total_bytes: int = 0 264 capture_bytes: int = 0 265 metadata_bytes: int = 0 266 #: Held by finished instances — what the two windows can reach. 267 finished_bytes: int = 0 268 #: What the windows in force made due on this pass. 269 due_bytes: int = 0 270 capture_retention_s: int = 0 271 archive_retention_s: int = 0 272 reclaimed_instances: int = 0 273 reclaimed_bytes: int = 0 274 failed: int = 0 275 kept: dict[KeepReason, int] = Field(default_factory=dict) 276 skipped: dict[SkipReason, int] = Field(default_factory=dict) 277 error: str = ""
The platform's instance-archive store and its last retention pass (#1714).
Held in process memory by run_instance_archive_retention and read
by GET /admin/instance-storage: absent until the first pass after
a restart, which the page shows as not measured yet.
558class AttachmentList(CtfyModel): 559 """Response payload for ``GET /challenges/{id}/attachments``. 560 561 Same data as ``ChallengeInfo.attachments`` but addressable directly 562 so pre-launch UI / agent tooling can fetch it without paging through 563 the catalog. Order matches the on-disk sort. 564 """ 565 566 files: list[AttachmentInfo] = Field(default_factory=list)
Response payload for GET /challenges/{id}/attachments.
Same data as ChallengeInfo.attachments but addressable directly
so pre-launch UI / agent tooling can fetch it without paging through
the catalog. Order matches the on-disk sort.
414class AuditFinding(CtfyModel): 415 """One rule's complaint about an uploaded tree.""" 416 417 #: `META007`, `STRUCT011`, `EXPLOIT007` — the id an author can look 418 #: up, which is why it travels rather than being folded into prose. 419 rule: str 420 #: `error` or `warning` as the audit spells them. Deliberately not 421 #: narrowed: `Severity` is the audit's enum and this crosses a 422 #: subprocess boundary as text, so a member a newer auditor grows 423 #: must reach the page rather than 500 on the way. 424 severity: str = "" 425 #: Relative to the benchmark directory, or empty for a finding about 426 #: the tree as a whole. 427 path: str = "" 428 #: ``None`` rather than ``0``: there is no line 0, so a falsy 429 #: default would render as a location the finding is not about. 430 line: int | None = None 431 message: str = ""
One rule's complaint about an uploaded tree.
115class AuthTokenResponse(CtfyModel): 116 """Returned by ``POST /auth/register`` and ``POST /auth/login``. 117 118 The plaintext user token is returned directly (not via fragment) since 119 these endpoints are called by first-party XHR, not redirects. 120 """ 121 122 token: str 123 redirect_to: str = "/"
Returned by POST /auth/register and POST /auth/login.
The plaintext user token is returned directly (not via fragment) since these endpoints are called by first-party XHR, not redirects.
34class AuthorReputation(CtfyModel): 35 """How players received an uploader's published challenges (plan P3.5). 36 37 Informational, and for reviewers and admins only: it orders the review 38 queue when asked and gates nothing — review is the control, never a 39 reward. Read through the curve that prices authoring on a 40 ``peer_authoring`` board, full marks for a challenge most entrants 41 fail and somebody finishes, and averaged over the uploads that have a 42 rate. 43 """ 44 45 #: The mean curve value over the uploads with a rate, between the 46 #: curve's floor and 1. ``None`` is *no reading* — nothing published, 47 #: or nothing tried by enough entrants to rate — never a zero. 48 score: float | None = None 49 #: How many published uploads had enough entrants to carry a rate. 50 priced: int = 0 51 #: How many published uploads were read at all. 52 published: int = 0
How players received an uploader's published challenges (plan P3.5).
Informational, and for reviewers and admins only: it orders the review
queue when asked and gates nothing — review is the control, never a
reward. Read through the curve that prices authoring on a
peer_authoring board, full marks for a challenge most entrants
fail and somebody finishes, and averaged over the uploads that have a
rate.
434class AuthoredAuditReport(CtfyModel): 435 """What `ctfy-admin challenge audit` says about one upload. 436 437 ⚠️ **An approval was a person reading code with no machine verdict 438 beside it.** The audit is the tool the author is told to run and the 439 platform never ran it, so the reviewer's evidence was the files — 440 and META/STRUCT/EXPLOIT findings are precisely what reading nine 441 files does not reliably catch. 442 443 ⚠️ ``outcome`` defaults to ``failed`` and the counts to zero, so a 444 producer that forgets to fill this in reports *the audit did not 445 run* rather than *the tree is clean*. The default must never be the 446 reassuring claim. 447 """ 448 449 outcome: AuditOutcome = "failed" 450 #: Counted over every finding the audit reported, **not** over the 451 #: cut list below: totals that shrank with the page would tell a 452 #: reviewer the tree is cleaner than it is. 453 errors: int = 0 454 warnings: int = 0 455 findings: list[AuditFinding] = Field(default_factory=list) 456 #: True when `findings` was cut. Said rather than implied — an 457 #: oldest-surviving finding that cannot say it is not the last is 458 #: the shape `traffic.live.retained` records. 459 truncated: bool = False
What ctfy-admin challenge audit says about one upload.
⚠️ An approval was a person reading code with no machine verdict beside it. The audit is the tool the author is told to run and the platform never ran it, so the reviewer's evidence was the files — and META/STRUCT/EXPLOIT findings are precisely what reading nine files does not reliably catch.
⚠️ outcome defaults to failed and the counts to zero, so a
producer that forgets to fill this in reports the audit did not
run rather than the tree is clean. The default must never be the
reassuring claim.
358class AuthoredAutoSmoke(CtfyModel): 359 """What the platform did about smoking one upload automatically. 360 361 ``docs/community-authoring-plan.md`` P2.2. A fact about the *upload 362 request*, not about the row — which is why it rides only on the write 363 response and is never stored: the verdict the run produces lands on 364 the row as ``smoke_verdict``, like any other smoke run's. 365 """ 366 367 outcome: AuthoredAutoSmokeOutcome 368 #: The person's runs per rolling day. ``None`` where no budget was 369 #: consulted (``off``, ``no_pool``, ``nothing_to_run``). 370 budget: int | None = None 371 #: Runs left in the window after this upload's. ``queued`` only. 372 remaining: int | None = None 373 #: When the oldest run in the window leaves it, which is when the 374 #: next upload can be smoked. ``budget_spent`` only. 375 available_at: AwareDatetime | None = None
What the platform did about smoking one upload automatically.
docs/community-authoring-plan.md P2.2. A fact about the upload
request, not about the row — which is why it rides only on the write
response and is never stored: the verdict the run produces lands on
the row as smoke_verdict, like any other smoke run's.
518class AuthoredBuildCapability(CtfyModel): 519 """Whether an approval on this deployment can become a live challenge. 520 521 ⚠️ **Composed server-side, for `expects_report`'s reason.** Both 522 facts — is anything marked, does the push exist — could be shipped 523 separately and `&&`-ed in the browser, and then two clients disagree 524 about whether an approval means anything. One answer, one composer. 525 526 ⚠️ **This exists because the page is where an operator forms the 527 belief.** `PlatformSettingInfo.in_force` records the same shape one 528 layer along: a setting that resolves ``true`` and does nothing is 529 read as a gate that is running. Here the equivalent is an operator 530 pressing Approve and going to look for a challenge that is not in 531 front of anybody. 532 533 ⚠️ ``can_build`` defaults **False** and the client reads it as such: 534 an older server that does not send this field has no untrusted-build 535 axis at all, so the honest reading of its silence is *no*. That is 536 the opposite default from most gates here, and deliberately — the 537 open direction would have the page promise a deployment capability 538 nothing implements. 539 """ 540 541 #: True only when a marked node exists **and** the push is built. 542 #: Today the second half is always false; the field is not a 543 #: constant because the first half is what an operator changes. 544 can_build: bool = False 545 #: Why not. ``""`` only when ``can_build`` is true. 546 blocked_reason: AuthoredBuildBlockedReason = "not_implemented" 547 #: How many healthy nodes carry the label. ``0`` beside 548 #: ``no_untrusted_build_node`` is the actionable case: mark one. 549 #: Counted rather than named, because a node id is an operator's 550 #: internal topology and the queue page is not where it is read. 551 marked_node_count: int = 0
Whether an approval on this deployment can become a live challenge.
⚠️ Composed server-side, for expects_report's reason. Both
facts — is anything marked, does the push exist — could be shipped
separately and &&-ed in the browser, and then two clients disagree
about whether an approval means anything. One answer, one composer.
⚠️ This exists because the page is where an operator forms the
belief. PlatformSettingInfo.in_force records the same shape one
layer along: a setting that resolves true and does nothing is
read as a gate that is running. Here the equivalent is an operator
pressing Approve and going to look for a challenge that is not in
front of anybody.
⚠️ can_build defaults False and the client reads it as such:
an older server that does not send this field has no untrusted-build
axis at all, so the honest reading of its silence is no. That is
the opposite default from most gates here, and deliberately — the
open direction would have the page promise a deployment capability
nothing implements.
790class AuthoredChallengeFileBody(CtfyModel): 791 """One file's contents, for the operator who has to read it. 792 793 ⚠️ **This is the point of the whole queue.** An approval is a person 794 saying "I read somebody else's code and it may run on our nodes"; 795 without a way to read it the verdict is a rubber stamp, and the 796 button would be worse than no button. The compose gate 797 (`ctfy.core.authored_compose`) rules on the declarative surface at 798 the door — a ``Dockerfile``'s ``RUN`` is exactly what no key list can 799 see, and this is where a human sees it. 800 801 ⚠️ **Plain text, never markup, and never a raw byte stream.** 802 ``LlmCallBody``'s rule for prose an untrusted image wrote applies 803 with more force here, because this text was written to be run: a 804 response the browser may sniff as HTML is stored XSS on an admin 805 page, served from the platform's own origin. 806 807 ⚠️ **``binary`` and ``truncated`` are separate facts and both are 808 published.** Decoding a compiled artifact with replacement 809 characters would show a reviewer mojibake and let them believe they 810 had read the file; a silent cut would let them believe they had read 811 all of it. Neither is a refusal — a reviewer who cannot open one 812 file must still be able to rule on the tree. 813 """ 814 815 path: str 816 #: The file's real size, always — so a truncated body still says how 817 #: much was not shown. 818 size_bytes: int = 0 819 #: Decoded text, empty when ``binary``. 820 text: str = "" 821 binary: bool = False 822 truncated: bool = False
One file's contents, for the operator who has to read it.
⚠️ This is the point of the whole queue. An approval is a person
saying "I read somebody else's code and it may run on our nodes";
without a way to read it the verdict is a rubber stamp, and the
button would be worse than no button. The compose gate
(ctfy.core.authored_compose) rules on the declarative surface at
the door — a Dockerfile's RUN is exactly what no key list can
see, and this is where a human sees it.
⚠️ Plain text, never markup, and never a raw byte stream.
LlmCallBody's rule for prose an untrusted image wrote applies
with more force here, because this text was written to be run: a
response the browser may sniff as HTML is stored XSS on an admin
page, served from the platform's own origin.
⚠️ binary and truncated are separate facts and both are
published. Decoding a compiled artifact with replacement
characters would show a reviewer mojibake and let them believe they
had read the file; a silent cut would let them believe they had read
all of it. Neither is a refusal — a reviewer who cannot open one
file must still be able to rule on the tree.
690class AuthoredChallengeFileEntry(CtfyModel): 691 """One file in an upload, as the detail page lists it.""" 692 693 path: str 694 size_bytes: int = 0 695 #: Where this file runs, if anywhere (plan P2.3): ``node`` — the 696 #: platform builds or runs it on its own machines — or ``player`` — 697 #: an executable or archive players download and run on theirs. 698 #: ``""`` is *runs nowhere*. Structural, from 699 #: `ctfy.core.execution_surface`: the compose file and the files' 700 #: first bytes. 701 executes: ExecutionSurfaceState = ""
One file in an upload, as the detail page lists it.
825class AuthoredChallengeFileRequest(CtfyModel): 826 """Which file of a stored tree to read (#1929). 827 828 ⚠️ **In the body, never in the URL.** The file routes took the member 829 path as a ``{path:path}`` segment, and a campus WAF in front of one 830 deployment blocks any request whose URL path ends in ``.ini`` — and 831 decodes ``%2E`` before matching, so encoding is no way round it. A 832 reviewer there could not read the ``php.ini`` they were reviewing. A 833 path-sniffing filter does not read a JSON body. 834 835 ⚠️ **A dictionary key, never a filesystem path**: nothing joins it to a 836 directory (`_file` looks it up in the stored tree), so it is not a 837 ``PathSegment`` sink. The bound is only a bound on the request. 838 """ 839 840 path: str = Field(min_length=1, max_length=4096)
Which file of a stored tree to read (#1929).
⚠️ In the body, never in the URL. The file routes took the member
path as a {path:path} segment, and a campus WAF in front of one
deployment blocks any request whose URL path ends in .ini — and
decodes %2E before matching, so encoding is no way round it. A
reviewer there could not read the php.ini they were reviewing. A
path-sniffing filter does not read a JSON body.
⚠️ A dictionary key, never a filesystem path: nothing joins it to a
directory (_file looks it up in the stored tree), so it is not a
PathSegment sink. The bound is only a bound on the request.
378class AuthoredChallengeInfo(AuthoredChallengeRow): 379 """One upload plus the paths in it — what a *write* answers with. 380 381 ⚠️ **A subclass rather than a second model**, which is what keeps 382 `admin_awd._info`'s defect from being re-earned: every shared field 383 reaches both shapes by construction, so a field added to the listing 384 row cannot be forgotten here. The one composer builds the base and 385 exactly one caller adds ``files``. 386 387 ⚠️ **Only where the tree is already in hand.** The upload route just 388 wrote the row, so the paths are free there; the listing holds 389 :class:`ctfy.core.authored_challenges.AuthoredChallengeSummary`, 390 which by construction has no tree — and an empty ``files`` on a 391 listing row would be a *claim* that the upload has no files, which 392 is the shape-5 reading this split exists to avoid. 393 """ 394 395 #: Sorted paths relative to the benchmark directory. 396 files: list[str] = Field(default_factory=list) 397 #: Whether this upload was smoked on the quarantine pool as it arrived 398 #: (P2.2). ⚠️ ``None`` is *not reported* — an older server, or a 399 #: queueing step that failed after the upload was stored — never 400 #: *not smoked*: the withheld value must not be a new claim. 401 auto_smoke: AuthoredAutoSmoke | None = None
One upload plus the paths in it — what a write answers with.
⚠️ A subclass rather than a second model, which is what keeps
admin_awd._info's defect from being re-earned: every shared field
reaches both shapes by construction, so a field added to the listing
row cannot be forgotten here. The one composer builds the base and
exactly one caller adds files.
⚠️ Only where the tree is already in hand. The upload route just
wrote the row, so the paths are free there; the listing holds
ctfy.core.authored_challenges.AuthoredChallengeSummary,
which by construction has no tree — and an empty files on a
listing row would be a claim that the upload has no files, which
is the shape-5 reading this split exists to avoid.
843class AuthoredChallengeReviewRequest(CtfyModel): 844 """An operator's verdict on one upload. 845 846 ⚠️ **A verdict can be revised**, as on ``review_harness``: a route 847 that only moved forward would leave a wrongly-approved tree 848 approved until somebody deleted the row. 849 """ 850 851 status: ReviewStatus 852 #: What the author reads. Required on a rejection — see 853 #: ``review_authored_challenge``. 854 note: str = ""
An operator's verdict on one upload.
⚠️ A verdict can be revised, as on review_harness: a route
that only moved forward would leave a wrongly-approved tree
approved until somebody deleted the row.
178class AuthoredChallengeRow(AuthoredDeclarations): 179 """One upload as the author's own listing shows it. 180 181 ⚠️ **The bytes do not travel, and now neither do the paths.** A tree 182 is up to ``MAX_TOTAL_BYTES`` and a listing is every upload the team 183 has ever made, so this model decides what that page costs. It used 184 to carry ``files: list[str]`` and the route therefore had to select 185 ``content_b64`` to produce them — measured at the 32 MiB ceiling of 186 the time, 85 MB of peak per upload and 427 MB for five, to render a 187 file **count**. 188 189 ⚠️ **The paths kept a reader, and finding it is what split this in 190 two.** The first version of this change deleted ``files`` outright, 191 on a measurement of the *production* surfaces: the page and the CLI 192 take a length and nothing reads a name. That population was the 193 wrong one — `test_mcp_authoring_tools` asserts on the **paths** 194 through the pack/unpack round trip, and its own comment says why a 195 count cannot stand in ("a packer that wrote the wrapping component 196 twice, or none, still sends three files"). So the names live on 197 :class:`AuthoredChallengeInfo`, which the **upload** answers with, 198 where the tree is already in hand and costs nothing. 199 """ 200 201 id: str 202 #: What the author called it, and what they address it by on every 203 #: surface. The id is minted, so this is the only half of the old 204 #: id that was theirs. 205 #: 206 #: ⚠️ **Unique per ``(competition_id, team_id)`` and no further.** 207 #: The id was globally unique, so one team taking ``vault`` denied 208 #: that name to every other team on the platform; the route had a 209 #: refusal for it and does not need one now. 210 slug: str = "" 211 #: ``metadata.yaml``'s own ``name:``, read at ingest. ``""`` means 212 #: the metadata did not say, and a reader falls back to `slug` — 213 #: never to the id, which is what this whole split removes from 214 #: anybody's screen. 215 title: str = "" 216 competition_id: str 217 team_id: str 218 #: Who pressed upload. Present so a captain can see which team-mate 219 #: sent this; the team is what gets the credit. 220 #: 221 #: ⚠️ **A uuid is not a reader**, which is why the field beside it 222 #: exists: this comment named an audience for the whole life of the 223 #: feature and no surface rendered either half. `AdminHarnessRow` 224 #: records the same finding one queue over — "the author is a uuid", 225 #: and a table of uuids is a table nobody triages. 226 user_id: str 227 #: The uploader's own display name, composed server-side. 228 #: 229 #: ⚠️ **Never their email**, which is where this parts company with 230 #: `admin_authoring._display_name`: that one falls back to the 231 #: address because its reader is an operator deciding whether to run 232 #: somebody's code, and `TeamMemberInfo` — the roster this same 233 #: squad already reads — publishes a display name and no address. 234 #: One shared helper would impose the operator's answer on a page 235 #: every team-mate can open. 236 #: 237 #: ⚠️ ``""`` means *no name to show* — the account is gone, or that 238 #: person never set one. The id travels beside it, so a reader can 239 #: still say "somebody on this team"; collapsing the two into the id 240 #: is what `_display_name`'s own docstring refuses. 241 author_display_name: str = "" 242 submitted_at: AwareDatetime | None = None 243 #: How many files the tree holds, and how many bytes they decode 244 #: to — so an author can tell a tree that arrived from one that 245 #: arrived empty without downloading it back. 246 #: 247 #: ⚠️ **These are stored columns, and they are what let the listing 248 #: stop reading the tree.** Producing them from ``content_b64`` is 249 #: what made the author's own listing select every byte their team 250 #: had ever uploaded: measured at a 32 MiB ``MAX_TOTAL_BYTES``, 85 MB 251 #: of peak for one upload and 427 MB for five, to render a count. 252 file_count: int = 0 253 total_bytes: int = 0 254 review_status: ReviewStatus = "pending" 255 reviewed_at: AwareDatetime | None = None 256 #: Why it was turned down. ``""`` while nobody has ruled. 257 review_note: str = "" 258 #: What running this tree's own reference exploit on a worker found, 259 #: or ``""`` if nobody has run one. 260 #: 261 #: ⚠️ **The author is the audience, and this is the half the audit 262 #: cannot produce.** `AuthoredAuditReport` reads files, so a 263 #: reference exploit that returns ``{}`` passes it clean — and an 264 #: unsolved board is what a hard challenge looks like too. Whoever 265 #: wrote the exploit is the only person who can fix that, and until 266 #: now the verdict reached an operator's task record and stopped. 267 #: 268 #: ⚠️ **Defaults to `""`, which claims nothing.** Any named member 269 #: would tell an author something about a run that never happened, 270 #: and `unsolved` in particular would accuse them of it. 271 smoke_verdict: AuthoredSmokeState = "" 272 #: When that run finished. Freshness — whether a verdict exists is 273 #: ``smoke_verdict``'s question, not a second field's. 274 smoke_at: AwareDatetime | None = None 275 #: A smoke run of this tree that has not produced its verdict yet: 276 #: ``queued`` waits its turn, ``running`` is on a worker now (leftover 277 #: L1). ``smoke_verdict`` above is still the last *finished* run's. 278 #: 279 #: ⚠️ **Why it exists**: the upload response says a run is queued, and 280 #: the listing then read "Not run" for as long as the run waited — the 281 #: one state that sends an author to upload again, spending another 282 #: run. Read off the task table (`ctfy.core.smoke_runs`), never stored. 283 #: 284 #: ⚠️ ``""`` is *no run in flight*, and also what a producer that did 285 #: not ask says: the display the row had before this field, never a 286 #: promise that a verdict is coming. 287 smoke_in_flight: AuthoredSmokeInFlightState = "" 288 #: Whether players can actually launch it: the tree is in this 289 #: deployment's catalog, read off the disk. 290 #: 291 #: ⚠️ **``review_status`` was answering three questions and could 292 #: only answer one.** An author read *approved* for a challenge 293 #: never published, for one that is live, and — once the removal 294 #: verb landed — for one taken back off the shelf. That is 295 #: `authored_publish`'s own recorded defect ("an operator who reads 296 #: it as *it is live* goes looking for a challenge that is not 297 #: there") pointed at the person who asks why their challenge is 298 #: not on the board. 299 #: 300 #: ⚠️ **Not the same as being in the event's challenge set**, and 301 #: the copy must not blur them: this is the catalog, while whether 302 #: an organiser curated it into ``challenge_ids`` is the field 303 #: below. A challenge can be live in the catalog and in no 304 #: competition. 305 #: 306 #: ⚠️ Defaults **False**, the loud direction and the admin row's: 307 #: an older server, or a producer that did not ask, reports *not 308 #: live*, which sends an author to ask rather than to assume. 309 in_catalog: bool = False 310 #: Where it stands with **this event's** challenge set — the step 311 #: after the one above, and the last one before entrants see it. 312 #: 313 #: ⚠️ **The sentence this replaced named a reader that does not 314 #: exist for most of this field's population.** ``in_catalog``'s 315 #: comment used to send the reader to 316 #: `AuthoredReceptionState.not_curated`, and measured, that route 317 #: answers ``{"scored": false, "rows": []}`` for every scoring rule 318 #: but ``peer_authoring`` — `authoring_enabled` is its own axis, so 319 #: an organiser collecting uploads on a jeopardy event leaves the 320 #: author with **no** surface for this at all. 321 #: 322 #: ⚠️ And the asymmetry ran the wrong way: `AdminAuthoredChallengeRow` 323 #: has carried `curation` since the operator's queue learned it, 324 #: composed from the competition row and therefore rule-independent. 325 #: The person who can *act* on it was told and the person *waiting* 326 #: on them was not. 327 #: 328 #: ⚠️ Same producer as the operator's (`server/authoring_curation.py`), 329 #: so the two cannot answer one upload differently — which is the 330 #: whole reason that module exists. 331 #: 332 #: ⚠️ Defaults ``not_curated``, the unfinished-looking reading: a 333 #: producer that did not ask must not tell an author their challenge 334 #: is in front of entrants. 335 curation: AuthoredCurationState = "not_curated" 336 #: The competitions **other than this upload's own** whose curated 337 #: set lists it — where it went after the event it was written for 338 #: (plan P1.5). ``curation`` above stays the own-event answer. 339 #: 340 #: ⚠️ From the **same producer** for both audiences 341 #: (`authoring_curation.curated_elsewhere`), `curation`'s rule — but 342 #: the author's copy names only competitions the author may see, so 343 #: an upload is not how a hidden event's name reaches somebody it is 344 #: hidden from. The operator's copy names all of them. 345 #: 346 #: ⚠️ Empty by default, and empty is a real answer: nothing curated 347 #: it anywhere else. A bucket competition never appears, because 348 #: nothing is curated into a set resolved from a catalog directory. 349 curated_elsewhere: list[AuthoredCurationElsewhere] = Field(default_factory=list) 350 #: The revision proposed for this upload and not yet applied — waiting, 351 #: approved or turned down — or ``None`` when there is none (plan P3.1). 352 #: 353 #: ⚠️ ``None`` is also what a producer that did not ask says, and it 354 #: claims nothing: the row is the live version either way. 355 revision: AuthoredRevisionRow | None = None
One upload as the author's own listing shows it.
⚠️ The bytes do not travel, and now neither do the paths. A tree
is up to MAX_TOTAL_BYTES and a listing is every upload the team
has ever made, so this model decides what that page costs. It used
to carry files: list[str] and the route therefore had to select
content_b64 to produce them — measured at the 32 MiB ceiling of
the time, 85 MB of peak per upload and 427 MB for five, to render a
file count.
⚠️ The paths kept a reader, and finding it is what split this in
two. The first version of this change deleted files outright,
on a measurement of the production surfaces: the page and the CLI
take a length and nothing reads a name. That population was the
wrong one — test_mcp_authoring_tools asserts on the paths
through the pack/unpack round trip, and its own comment says why a
count cannot stand in ("a packer that wrote the wrapping component
twice, or none, still sends three files"). So the names live on
AuthoredChallengeInfo, which the upload answers with,
where the tree is already in hand and costs nothing.
67class AuthoredCurationElsewhere(CtfyModel): 68 """Another competition an upload has been curated into. 69 70 ``docs/community-authoring-plan.md`` P1.5: the workshop → arena step 71 curates an upload into a competition that is not its own, and the 72 author's page has to be able to say it graduated. The competition's 73 own id and title, because the title is what a person reads and the 74 id is what a client links to. 75 """ 76 77 competition_id: str 78 #: ``""`` only if the competition had no title — every reader then 79 #: renders the id. 80 competition_title: str = ""
Another competition an upload has been curated into.
docs/community-authoring-plan.md P1.5: the workshop → arena step
curates an upload into a competition that is not its own, and the
author's page has to be able to say it graduated. The competition's
own id and title, because the title is what a person reads and the
id is what a client links to.
83class AuthoredDeclarations(CtfyModel): 84 """What an author declared about an upload under the contributor 85 agreement, and which agreement they accepted 86 (`docs/community-authoring-plan.md` P1.3). 87 88 The base of every row model that describes an upload, so a field 89 added here reaches the author's listing and the operator's queue 90 alike — the composers carry by field-set intersection. 91 92 ⚠️ ``""`` on ``license``, ``provenance`` and ``ai_assisted`` is 93 **nobody said** — an upload from before the agreement existed, or to 94 an event that asks for none — and a reader takes the strictest 95 meaning: platform only, not known. It is never shown as an answer. 96 The two grants default to their refusal instead: no evaluation use, 97 anonymous credit. 98 """ 99 100 #: One of six licences (decision 9), or ``""`` for none declared. 101 license: AuthoredLicenseState = "" 102 #: ``original`` or ``port``, or ``""`` for none declared. 103 provenance: AuthoredProvenanceState = "" 104 #: What a port was ported from; only a ``port`` carries one. 105 source_url: str = "" 106 #: ``yes`` / ``no``, or ``""`` — **not known**, never read as ``no``. 107 ai_assisted: AuthoredAiAssistanceState = "" 108 #: Whether the platform may use it to evaluate AI agents. 109 eval_use: bool = False 110 #: How the author is credited in public. 111 credit: AuthoredCredit = "anonymous" 112 #: The agreement version accepted at upload, stamped from the 113 #: competition; ``""`` when the event asked for none. 114 terms_version: str = "" 115 terms_accepted_at: AwareDatetime | None = None 116 #: Where it goes once published (plan P3.4): ``public``, the catalog 117 #: every competition may curate, or ``eval_only`` — the evaluation 118 #: pool, kept out of every public listing and launched only through a 119 #: competition that is not public and curates it. Only an original 120 #: upload whose author granted evaluation use may choose the pool. 121 destination: AuthoredDestination = "public"
What an author declared about an upload under the contributor
agreement, and which agreement they accepted
(docs/community-authoring-plan.md P1.3).
The base of every row model that describes an upload, so a field added here reaches the author's listing and the operator's queue alike — the composers carry by field-set intersection.
⚠️ "" on license, provenance and ai_assisted is
nobody said — an upload from before the agreement existed, or to
an event that asks for none — and a reader takes the strictest
meaning: platform only, not known. It is never shown as an answer.
The two grants default to their refusal instead: no evaluation use,
anonymous credit.
765class AuthoredGraduationInfo(CtfyModel): 766 """Whether an upload may graduate into the challenge corpus, and as what. 767 768 ``docs/community-authoring-plan.md`` P3.3. The platform opens nothing: 769 this is the answer a platform admin reads before downloading the bundle 770 (``GET .../graduation/bundle``) that a person carries to the corpus 771 repository as a pull request, whose own review is the gate. 772 """ 773 774 challenge_id: str 775 #: Every reason it may not graduate yet, collected — empty when it may. 776 #: A code a client renders in its reader's language, with the English 777 #: beside it for one that has no copy for the code. 778 refusals: list[AuthoringRefusal] = Field(default_factory=list) 779 #: The corpus id it would take: the one asked for, or else the next 780 #: ``COMMUNITY-NNN`` this deployment's catalog does not hold. 781 corpus_id: str 782 #: The licence its author declared, which the bundle's ``LICENSE`` 783 #: carries. ``""`` is nothing declared, which refuses. 784 license: AuthoredLicenseState = "" 785 #: The words for the pull request a person opens with the bundle. 786 pull_request_title: str 787 pull_request_body: str
Whether an upload may graduate into the challenge corpus, and as what.
docs/community-authoring-plan.md P3.3. The platform opens nothing:
this is the answer a platform admin reads before downloading the bundle
(GET .../graduation/bundle) that a person carries to the corpus
repository as a pull request, whose own review is the gate.
55class AuthoredQualityFlag(CtfyModel): 56 """Players' reports on an upload that reached the threshold (plan P2.6). 57 58 One per report kind — ``buggy``, ``infra_broken`` or ``guessy`` — with 59 how many different people gave it, counted against the current 60 version of the tree. 61 """ 62 63 reaction: Reaction 64 count: int
Players' reports on an upload that reached the threshold (plan P2.6).
One per report kind — buggy, infra_broken or guessy — with
how many different people gave it, counted against the current
version of the tree.
894class AuthoredReceptionRow(CtfyModel): 895 """How the field fared against one challenge its author wrote. 896 897 ⚠️ **The counts are the rate's own inputs, not a reading of it.** 898 `PeerAuthoringContext` carries ``solved`` and ``population`` beside 899 ``rates`` precisely so this row does not have to recover a headcount 900 from a float — ``round(rate * field_size)`` is a second spelling of 901 the division that produced it, and the two disagree the moment 902 either rounds. 903 """ 904 905 challenge_id: str 906 #: The catalog's name for it, or ``""`` when the spec has gone — 907 #: never the id twice, which reads as a name. 908 name: str = "" 909 state: AuthoredReceptionState = "not_curated" 910 #: How many entrants of the author's own division fully solved it. 911 solvers: int = 0 912 #: The denominator: that division's size minus the author, who 913 #: cannot solve their own upload. ``0`` when there is no rate. 914 field_size: int = 0 915 #: ``solvers / field_size``, or ``None`` when ``state`` is not 916 #: ``priced``. ⚠️ Never ``0.0`` for an unpriced row: zero is the 917 #: floor of the curve and a real verdict ("nobody could solve it"), 918 #: which is exactly the sentence an uncurated challenge has not 919 #: earned. 920 solve_rate: float | None = None 921 #: What the curve pays for that rate, in ``[floor, 1]`` — the 922 #: multiplier the weight is applied to. ``None`` with ``solve_rate``. 923 multiplier: float | None = None
How the field fared against one challenge its author wrote.
⚠️ The counts are the rate's own inputs, not a reading of it.
PeerAuthoringContext carries solved and population beside
rates precisely so this row does not have to recover a headcount
from a float — round(rate * field_size) is a second spelling of
the division that produced it, and the two disagree the moment
either rounds.
740class AuthoredRevisionDiff(CtfyModel): 741 """A proposed revision against the live tree — what a reviewer reads. 742 743 ⚠️ **The diff, because the reviewer has read the live version.** A 744 second review costs the size of the change only if the change is what 745 they are shown; unchanged files are counted, not listed, and remain 746 readable through the upload's own file route. 747 """ 748 749 revision: AdminAuthoredRevisionRow 750 changes: list[AuthoredRevisionFileChange] = Field(default_factory=list) 751 unchanged: int = 0 752 #: Question ids the live tree serves and the revision would not. Always 753 #: empty for a stored proposal — the door refuses one that drops any — 754 #: and asked again at apply. 755 dropped_questions: list[str] = Field(default_factory=list) 756 #: The revision's whole tree, with where each file runs (plan P2.3). 757 files: list[AuthoredChallengeFileEntry] = Field(default_factory=list) 758 #: Whether applying asks the platform admin to confirm they read the 759 #: files marked ``executes``: the upload is published and the revision 760 #: has an execution surface. An unpublished upload's review happens at 761 #: its publish instead. 762 needs_exec_review: bool = False
A proposed revision against the live tree — what a reviewer reads.
⚠️ The diff, because the reviewer has read the live version. A second review costs the size of the change only if the change is what they are shown; unchanged files are counted, not listed, and remain readable through the upload's own file route.
723class AuthoredRevisionFileChange(CtfyModel): 724 """One path a revision changes (plan P3.1, `ctfy.core.authored_revisions`).""" 725 726 path: str 727 change: FileChangeKind 728 #: Decoded sizes; ``0`` on the side where the file does not exist. 729 size_before: int = 0 730 size_after: int = 0 731 #: A unified diff from the live file to the revision's, or ``""`` when 732 #: ``omitted`` says why not. 733 diff: str = "" 734 #: ``binary``, ``too_large`` or ``over_budget`` — ⚠️ never ``""`` beside 735 #: an empty diff of a changed file, which would read as *nothing to 736 #: see* about a file the reviewer was simply not shown. 737 omitted: DiffOmission = ""
One path a revision changes (plan P3.1, ctfy.core.authored_revisions).
159class AuthoredRevisionInfo(AuthoredRevisionRow): 160 """A revision plus the paths in it — what proposing one answers with, 161 where the tree is already in hand (`AuthoredChallengeInfo`'s rule).""" 162 163 files: list[str] = Field(default_factory=list)
A revision plus the paths in it — what proposing one answers with,
where the tree is already in hand (AuthoredChallengeInfo's rule).
124class AuthoredRevisionRow(CtfyModel): 125 """A revision of a reviewed upload, as its author reads it (plan P3.1). 126 127 ``docs/community-authoring-plan.md`` P3.1: one of the upload's recorded 128 authors proposes a new tree for the same id; a reviewer rules on it as 129 a diff against the live version; a platform admin applies it. 130 131 ⚠️ **No operator ids**, `AuthoredChallengeRow`'s rule: who ruled is the 132 admin queue's business, and the author reads the verdict and the note. 133 """ 134 135 id: str 136 #: The upload it revises. 137 authored_challenge_id: str 138 #: Who proposed it — one of the upload's recorded authors. 139 user_id: str 140 #: What to call them, composed server-side for the audience reading it 141 #: (`AuthoredChallengeRow.author_display_name`'s rule); ``""`` is *no 142 #: name to show*. 143 author_display_name: str = "" 144 #: The revision's own ``metadata.yaml`` ``name:``; the upload takes it 145 #: when the revision is applied. 146 title: str = "" 147 submitted_at: AwareDatetime | None = None 148 file_count: int = 0 149 total_bytes: int = 0 150 review_status: ReviewStatus = "pending" 151 reviewed_at: AwareDatetime | None = None 152 #: Why it was turned down, or what the reviewer said approving it. 153 review_note: str = "" 154 #: When a platform admin put it live. ``None`` is *a proposal* — the 155 #: upload still serves its own tree. 156 applied_at: AwareDatetime | None = None
A revision of a reviewed upload, as its author reads it (plan P3.1).
docs/community-authoring-plan.md P3.1: one of the upload's recorded
authors proposes a new tree for the same id; a reviewer rules on it as
a diff against the live version; a platform admin applies it.
⚠️ No operator ids, AuthoredChallengeRow's rule: who ruled is the
admin queue's business, and the author reads the verdict and the note.
926class AuthoringReceptionInfo(CtfyModel): 927 """What this caller's team earned for the challenges it wrote. 928 929 ⚠️ **Composed from the board's own producer**, never from a second 930 tally: the rows come out of `PeerAuthoringRule.prepare` run over the 931 facts `board_facts` scopes, and ``authoring_score`` out of the same 932 rule's ``score``. A page that summed the multipliers itself would be 933 a second implementation of the rounding, free to disagree with the 934 scoreboard on the next tab. 935 """ 936 937 #: Whether this competition scores authoring at all. ``False`` on 938 #: every other rule, and the rows are then empty. 939 scored: bool = False 940 #: The division the rates above are against — a rate is per 941 #: division, so the number is unreadable without it. 942 division: AuthoringDivision = "" 943 #: The competition's stored multiplier. ``0`` is the default and 944 #: means authoring currently pays nothing, which is a real state 945 #: rather than a missing one. 946 authoring_weight: float = 0.0 947 #: What the board credits this team for authoring, right now. 948 #: 949 #: ⚠️ Recomputed here rather than read off the cached board, so this 950 #: page can be a moment fresher than the scoreboard's TTL. The two 951 #: cannot *disagree* — same rule, same facts — they can only be at 952 #: different instants, which every live number on the platform is. 953 authoring_score: int = 0 954 rows: list[AuthoredReceptionRow] = Field(default_factory=list)
What this caller's team earned for the challenges it wrote.
⚠️ Composed from the board's own producer, never from a second
tally: the rows come out of PeerAuthoringRule.prepare run over the
facts board_facts scopes, and authoring_score out of the same
rule's score. A page that summed the multipliers itself would be
a second implementation of the rounding, free to disagree with the
scoreboard on the next tab.
1339class AuthoringReviewerInfo(CtfyModel): 1340 """One per-competition authoring-reviewer grant, for the super-admin 1341 card on the competition detail page. 1342 1343 ``docs/community-authoring-plan.md`` P2.3, decision 16: the holder 1344 rules on the content of this event's uploaded challenges and reaches 1345 nothing else — not the organiser's console, not the entrants' 1346 registration details. Its own row type rather than a third 1347 ``CompetitionAdminInfo.role``, because every reader of that table 1348 decides something this grant must not reach. 1349 """ 1350 1351 user_id: str 1352 display_name: str = "" 1353 email: str = "" 1354 granted_by: str = "" 1355 #: What the super-admin who granted it is called — a user id on the 1356 #: wire carries its label beside it. 1357 granted_by_display_name: str = "" 1358 granted_at: datetime | None = None
One per-competition authoring-reviewer grant, for the super-admin card on the competition detail page.
docs/community-authoring-plan.md P2.3, decision 16: the holder
rules on the content of this event's uploaded challenges and reaches
nothing else — not the organiser's console, not the entrants'
registration details. Its own row type rather than a third
CompetitionAdminInfo.role, because every reader of that table
decides something this grant must not reach.
356class AuthoringScaleInfo(CtfyModel): 357 """What a peer-authoring board's ``authoring_weight`` is worth. 358 359 ⚠️ **The reading is "how many solved challenges", not "what share of 360 the total", and the missing denominator is why.** A share needs to 361 know how many challenges a team writes and how many it solves; the 362 platform holds neither — ``max_pending_authored_challenges`` bounds a 363 review queue, not an event — so a percentage here could only come 364 from an assumption, and an assumption rendered as a figure is read as 365 a measurement. 366 367 ⚠️ **Composed here because the price is.** ``scoring_params`` crosses 368 as ``dict[str, Any]``, so a browser-side version would be a second 369 implementation of the point table and its decay — see 370 ``ctfy.server.authoring_scale``. 371 """ 372 373 #: Whether this competition scores authoring at all — the producer's 374 #: own ``isinstance`` verdict on the **stored** rule, which it used 375 #: to compute and drop. 376 #: 377 #: ⚠️ **Every other field below is a default on a ``False``, and the 378 #: defaults are indistinguishable from real answers.** A rule that is 379 #: not ``peer_authoring`` returns a bare row, so ``challenges_priced`` 380 #: reads ``0`` and ``equivalent_challenges`` reads ``None`` however 381 #: full the challenge set is — measured across four rules against a 382 #: set with a priceable challenge in it. A reader that branches on 383 #: those without asking this one first says *nothing in this event's 384 #: challenge set can be priced yet* about a set that is priced and 385 #: curated, so this is asked **before** them, never instead of them. 386 #: 387 #: ⚠️ ``False`` by default, `AuthoringReceptionInfo.scored`'s rule on 388 #: the same question: a producer that forgets it takes a real 389 #: reading away, which somebody reports, where the other direction 390 #: puts the false sentence back in silence. 391 scored: bool = False 392 #: The stored weight, echoed so a reader knows which number the rest 393 #: of this row is about. Zero for a competition that has not saved 394 #: the rule, which is the honest answer rather than a second state: 395 #: authoring scores nothing there. 396 weight: float = 0.0 397 #: How many of the competition's challenges could be priced. 398 challenges_priced: int = 0 399 #: The typical challenge's full-solve price. Shipped beside the ratio 400 #: because it is what makes the ratio checkable against the 401 #: organiser's own point table. 402 median_challenge_points: int = 0 403 #: ⚠️ ``None``, never ``0.0``, for a set nothing could be priced 404 #: from. Zero is a statement about the *weight* ("authoring is worth 405 #: nothing here"); an empty challenge set says nothing about the 406 #: weight at all, and the two get opposite advice. 407 equivalent_challenges: float | None = None 408 #: Whether the solving half decays. When it does, the solving side 409 #: only shrinks as the field grows, so the ratio is a **floor** — the 410 #: direction that matters, since understating it is what makes an 411 #: organiser pick a weight larger than they meant. 412 decays: bool = False
What a peer-authoring board's authoring_weight is worth.
⚠️ The reading is "how many solved challenges", not "what share of
the total", and the missing denominator is why. A share needs to
know how many challenges a team writes and how many it solves; the
platform holds neither — max_pending_authored_challenges bounds a
review queue, not an event — so a percentage here could only come
from an assumption, and an assumption rendered as a figure is read as
a measurement.
⚠️ Composed here because the price is. scoring_params crosses
as dict[str, Any], so a browser-side version would be a second
implementation of the point table and its decay — see
ctfy.server.authoring_scale.
462class AuthoringTemplateFile(CtfyModel): 463 """One file in a starter template, as the download would write it.""" 464 465 #: Relative to the benchmark directory, so it reads the way the 466 #: spec does (``services/app/main.go``) rather than carrying the 467 #: archive's wrapping directory, which is an artifact of the 468 #: transport and not something the author will ever see on disk. 469 path: str 470 size_bytes: int = 0
One file in a starter template, as the download would write it.
473class AuthoringTemplateManifest(CtfyModel): 474 """What ``GET /authoring/template`` would hand over, without handing it. 475 476 ⚠️ **The page downloaded a ``.tar.gz`` blind.** A picker with nine 477 shapes behind it and no way to see inside one is a choice made from 478 a label; the listing is what turns it into a choice made from the 479 thing. 480 481 ⚠️ **Derived from the same `scaffold_authored`, never described.** 482 A hand-written listing beside a live scaffolder is the second 483 specification this feature already refused once — the reason the 484 download route *builds* the tree per request rather than serving a 485 checked-in archive — and it would go stale on whichever path nobody 486 exercised. So this route builds the tree too and reads it back; 487 `TestTheManifestIsTheArchive` drives both and compares the sets, 488 because two producers of one fact is the whole hazard. 489 490 ⚠️ ``total_bytes`` is the **decoded** sum rather than the archive's 491 size, so it is the same quantity `AuthoredChallengeInfo.total_bytes` 492 reports on the upload that comes back. Two numbers for one fact is 493 the recurring cost here; the one an author can compare is worth 494 more than the one that measures the transfer. 495 """ 496 497 files: list[AuthoringTemplateFile] = Field(default_factory=list) 498 total_bytes: int = 0
What GET /authoring/template would hand over, without handing it.
⚠️ The page downloaded a .tar.gz blind. A picker with nine
shapes behind it and no way to see inside one is a choice made from
a label; the listing is what turns it into a choice made from the
thing.
⚠️ Derived from the same scaffold_authored, never described.
A hand-written listing beside a live scaffolder is the second
specification this feature already refused once — the reason the
download route builds the tree per request rather than serving a
checked-in archive — and it would go stale on whichever path nobody
exercised. So this route builds the tree too and reads it back;
TestTheManifestIsTheArchive drives both and compares the sets,
because two producers of one fact is the whole hazard.
⚠️ total_bytes is the decoded sum rather than the archive's
size, so it is the same quantity AuthoredChallengeInfo.total_bytes
reports on the upload that comes back. Two numbers for one fact is
the recurring cost here; the one an author can compare is worth
more than the one that measures the transfer.
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.
750class AwdClockBrief(CtfyModel): 751 """The compact round clock a list card / header progress bar draws. 752 753 ⚠️ **The same round arithmetic as ``AwdMatchStatus``, carried on the 754 *list* row** so a card can draw "round N / M · this round ends in 755 mm:ss" without a per-row ``/awd/match`` fetch — the N+1 shape 756 ``awd_match_ids`` / ``team_counts`` already remove for this 757 projection. It is a brief, not the full status: the budgets and the 758 ids on ``AwdMatchStatus`` are redundant on a row that already names 759 the competition, and a card never reads them. 760 761 ⚠️ **Never recomputed in the browser.** ``current_tick`` / 762 ``tick_ends_at`` / ``ends_at`` come off ``ctfy.core.awd`` here, 763 exactly as ``AwdMatchStatus`` documents, so the card, the header and 764 the match page cannot disagree at a round boundary — the copy that 765 re-derives ``starts_at + tick_seconds * n`` is the one that is wrong 766 at the instant a room is looking at the clock. 767 768 ⚠️ **``None`` on ``CompetitionInfo`` means "not a match / not 769 scheduled", not "round zero".** ``current_tick`` is ``None`` before 770 the first round and after the last (``core.awd`` represents the gap 771 as a real state), and ``last_tick_done`` is ``-1`` before anything 772 settles — the same sentinels the full status carries. 773 """ 774 775 #: The match's kickoff — when its first round opens. 776 #: 777 # ⚠️ **The match's own ``starts_at``, never the competition's.** The two 778 # are different instants on purpose (`core.awd.provisioning_window_s` 779 # says why), and the gap is the ordinary case rather than an edge: 780 # `admin competition create --awd-starts-at …` writes a competition 781 # with no window of its own, so ``CompetitionInfo.starts_at`` is unset 782 # and the competition reads as running from the moment it is created. 783 # Without this field the brief could not say that nothing has started 784 # — ``current_tick`` is ``None`` both before the first round and after 785 # the last — and a header drew "round 0 of 12" under a 进行中 chip for 786 # the whole wait before kickoff, with no countdown anywhere. 787 # 788 # ⚠️ ``None`` only where the whole brief is (an unscheduled match); a 789 # brief exists only for a match that has a kickoff. 790 starts_at: datetime | None = None 791 #: The round length and count — the denominators the bar needs. 792 tick_seconds: int = 0 793 tick_count: int = 0 794 #: The round in progress, or ``None`` before the first and after the 795 #: last. Never stored; recomputed on every read. 796 current_tick: int | None = None 797 #: When the round in progress ends. ``None`` when none is. 798 tick_ends_at: datetime | None = None 799 #: When the last round closes — the match's own end. ``None`` when the 800 #: match has no ``starts_at`` (unscheduled), never an epoch. 801 ends_at: datetime | None = None 802 #: The last round settled — the one a board is showing. ``-1`` means 803 #: none has been, which is not round 0. 804 last_tick_done: int = -1
The compact round clock a list card / header progress bar draws.
⚠️ The same round arithmetic as AwdMatchStatus, carried on the
list row so a card can draw "round N / M · this round ends in
mm:ss" without a per-row /awd/match fetch — the N+1 shape
awd_match_ids / team_counts already remove for this
projection. It is a brief, not the full status: the budgets and the
ids on AwdMatchStatus are redundant on a row that already names
the competition, and a card never reads them.
⚠️ Never recomputed in the browser. current_tick /
tick_ends_at / ends_at come off ctfy.core.awd here,
exactly as AwdMatchStatus documents, so the card, the header and
the match page cannot disagree at a round boundary — the copy that
re-derives starts_at + tick_seconds * n is the one that is wrong
at the instant a room is looking at the clock.
⚠️ None on CompetitionInfo means "not a match / not
scheduled", not "round zero". current_tick is None before
the first round and after the last (core.awd represents the gap
as a real state), and last_tick_done is -1 before anything
settles — the same sentinels the full status carries.
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.
64class BlueprintHostInfo(CtfyModel): 65 """One service in a range's estate, as an organiser sees it.""" 66 67 name: str 68 segments: list[str] = Field(default_factory=list) 69 published: bool = False 70 #: Milestones whose value this service is handed. 71 milestones: list[str] = Field(default_factory=list) 72 #: Shares no network with anything published, so reaching it costs a 73 #: foothold. ⚠️ Defaults **False** — the claim a wrong value makes 74 #: matters here, and "reachable" is the one that merely costs a 75 #: second look, while a wrong "internal" tells an organiser a box is 76 #: protected when it is on the public segment. 77 behind_pivot: bool = False 78 #: ``estate`` | ``infrastructure`` 79 role: HostRole = HostRole.ESTATE
One service in a range's estate, as an organiser sees it.
82class BlueprintMilestoneInfo(CtfyModel): 83 """One milestone, placed in the estate and in the chain.""" 84 85 id: str 86 #: ``dynamic`` | ``static`` | ``seeded`` | ``single_select`` | 87 #: ``multi_select`` | ``checker`` | ``unknown`` 88 mode: MilestoneMode = MilestoneMode.UNKNOWN 89 requires: list[str] = Field(default_factory=list) 90 #: Services carrying the value — **plural**, because a capability 91 #: with several producers means several services legitimately hold a 92 #: valid answer and naming one would invent a fact. 93 hosts: list[str] = Field(default_factory=list) 94 #: Which score capturing it feeds: ``capability`` or ``reporting``. 95 #: 96 #: ⚠️ **On the organiser's blueprint and on no player surface**, and 97 #: the split is deliberate rather than an oversight — see 98 #: ``BlueprintMilestone.scores``. Telling a player which milestones 99 #: are cul-de-sac evidence hands over the triage judgement the 100 #: dead-end tier exists to measure; telling an *organiser* is the 101 #: only way they can see the chain apart from the wrong turns on a 102 #: range they did not author. 103 scores: str = "capability" 104 #: 1 for a milestone with no prerequisite; 0 means the ``requires`` 105 #: graph has a cycle through this id. 106 depth: int = 1
One milestone, placed in the estate and in the chain.
718class BoardCriterion(CtfyModel): 719 """One key a board ranks by, in precedence order. 720 721 A board shows a rank and a number. When two rows carry the same 722 number, what separated them was decided by the scoring rule's 723 tie-break and is invisible to everyone reading the page — including 724 the two teams it ranked. This says what the keys were. 725 726 ⚠️ **No value travels with it.** ``field`` names a 727 :class:`ScoreboardEntry` attribute, and the client reads the number 728 off the row it already holds — publishing the values again per row 729 would be a second copy that can disagree with the figures printed 730 beside them, which is the one failure a sort *explanation* must not 731 have. 732 733 ⚠️ **Derived from the rule's own ``order``, never described beside 734 it** — the criteria are what ``sort_key`` is built from, so this 735 cannot drift from the sort it explains. See 736 ``ctfy.server.scoring.base.SortCriterion``. 737 """ 738 739 #: A ``ScoreboardEntry`` field name. ``test_the_ordering_is_derived`` 740 #: checks every one against the model, because a name the model does 741 #: not carry reaches the browser as ``undefined`` and renders as a 742 #: blank cell beside a criterion claiming to have decided the rank. 743 field: str = "" 744 #: True when more is better. ⚠️ Not a "direction" string: the two 745 #: values are a closed set of exactly two, and the one place a reader 746 #: needs them is an arrow. 747 descending: bool = False
One key a board ranks by, in precedence order.
A board shows a rank and a number. When two rows carry the same number, what separated them was decided by the scoring rule's tie-break and is invisible to everyone reading the page — including the two teams it ranked. This says what the keys were.
⚠️ No value travels with it. field names a
ScoreboardEntry attribute, and the client reads the number
off the row it already holds — publishing the values again per row
would be a second copy that can disagree with the figures printed
beside them, which is the one failure a sort explanation must not
have.
⚠️ Derived from the rule's own order, never described beside
it — the criteria are what sort_key is built from, so this
cannot drift from the sort it explains. See
ctfy.server.scoring.base.SortCriterion.
700class BuildPushScopeRequest(CtfyModel): 701 """What ``POST /admin/challenges/build-push`` freezes (Layer 2 P0). 702 703 An explicit scope is **required** — the plan freezes common families 704 first, manually, and defaulting to the whole ~2400-challenge corpus 705 would be an enormous unintended build campaign. Give a ``bucket`` 706 (family directory such as ``XBOW``, matched case-insensitively) or an 707 explicit ``challenge_ids`` list, or both (their union). An empty 708 request is refused by the route. 709 """ 710 711 #: Family / bucket directory to freeze — every challenge whose bucket 712 #: matches (e.g. ``XBOW``). ``""`` selects none by bucket. 713 bucket: str = "" 714 #: Explicit challenge ids to freeze, on top of any ``bucket`` match. 715 challenge_ids: list[str] = Field(default_factory=list) 716 #: Build host to run the freeze on. ``""`` picks the first online node 717 #: that runs each challenge; set it to freeze on one specific node 718 #: (e.g. the dedicated build host ``cb``). 719 node_id: str = ""
What POST /admin/challenges/build-push freezes (Layer 2 P0).
An explicit scope is required — the plan freezes common families
first, manually, and defaulting to the whole ~2400-challenge corpus
would be an enormous unintended build campaign. Give a bucket
(family directory such as XBOW, matched case-insensitively) or an
explicit challenge_ids list, or both (their union). An empty
request is refused by the route.
1455class BulkInviteRequest(CtfyModel): 1456 """Body for ``POST /admin/competitions/{id}/invites/bulk``. 1457 1458 Organisers arrive with a qualifying list — a column pasted out of a 1459 spreadsheet, newline- or comma-separated, usually with stray spaces 1460 and the odd duplicate. Splitting it server-side keeps every client 1461 (web, CLI, SDK) from having to agree on the same rules. 1462 """ 1463 1464 # One blob rather than a parsed list: a textarea is what the organiser 1465 # actually has, and asking the client to split it invites three 1466 # slightly different splitters. 1467 emails: str = Field(default="", max_length=200_000)
Body for POST /admin/competitions/{id}/invites/bulk.
Organisers arrive with a qualifying list — a column pasted out of a spreadsheet, newline- or comma-separated, usually with stray spaces and the odd duplicate. Splitting it server-side keeps every client (web, CLI, SDK) from having to agree on the same rules.
1483class BulkInviteResponse(CtfyModel): 1484 """Per-address outcomes plus the tallies the toast reports. 1485 1486 Every address is echoed back rather than only the failures: an 1487 organiser pasting 300 lines needs to see that 300 were understood, 1488 and a silent drop is indistinguishable from success. 1489 """ 1490 1491 results: list[BulkInviteResultRow] = Field(default_factory=list) 1492 invited: int = 0 1493 pending: int = 0 1494 already: int = 0 1495 invalid: int = 0
Per-address outcomes plus the tallies the toast reports.
Every address is echoed back rather than only the failures: an organiser pasting 300 lines needs to see that 300 were understood, and a silent drop is indistinguishable from success.
1470class BulkInviteResultRow(CtfyModel): 1471 """What happened to one address. ``status`` is one of: 1472 1473 ``invited`` — a new allowlist row (the user existed); 1474 ``pending`` — recorded against the address, waiting for a sign-up; 1475 ``already`` — invited before, so nothing changed; 1476 ``invalid`` — not an email address, skipped. 1477 """ 1478 1479 email: str 1480 status: str
What happened to one address. status is one of:
invited — a new allowlist row (the user existed);
pending — recorded against the address, waiting for a sign-up;
already — invited before, so nothing changed;
invalid — not an email address, skipped.
441class CalendarBucket(CtfyModel): 442 """One UTC-day cell on the contribution calendar. 443 444 ``count`` aggregates correct submissions + first-time solves on that 445 day; effectively "did anything productive happen". The series is 446 dense — every day in the requested window is present, zero-filled.""" 447 448 date: str = "" # YYYY-MM-DD, UTC 449 count: int = 0
One UTC-day cell on the contribution calendar.
count aggregates correct submissions + first-time solves on that
day; effectively "did anything productive happen". The series is
dense — every day in the requested window is present, zero-filled.
109class ChallengeBlueprint(CtfyModel): 110 """A range's topology and kill chain, for the people running it. 111 112 ⚠️ **Derived, never carried.** Every field is read off the 113 challenge's own ``metadata.yaml`` and ``docker-compose.yml``, which 114 the deployment already has for every benchmark in the corpus. A 115 generator emitting a sidecar plan file would have covered only 116 generated ranges — leaving the hand-built ones, which are the reason 117 the format exists, with no view at all — and would have been a 118 second copy of facts the compose already carries, free to disagree 119 with the estate that actually boots. 120 121 ⚠️ **No groundtruth here.** This model says *what a milestone is and 122 where it lives*, never what it is worth. The value of a minted 123 answer is a per-instance fact and lives on the instance route, where 124 reading it is an audited act. 125 """ 126 127 challenge_id: str 128 segments: list[str] = Field(default_factory=list) 129 hosts: list[BlueprintHostInfo] = Field(default_factory=list) 130 milestones: list[BlueprintMilestoneInfo] = Field(default_factory=list) 131 #: Milestones no service carries. ⚠️ Projected rather than dropped: 132 #: a page claiming an estate is fully mapped when a sixth of it is 133 #: not is worse than no page. 134 unplaced: list[str] = Field(default_factory=list) 135 #: Nothing is published — no ``ports:`` anywhere and no entry the 136 #: platform injects in their place — so ``behind_pivot`` has no 137 #: meaning and is False throughout. An engagement range whose entry 138 #: the platform injects is *not* this, whatever its compose says. 139 tunnelled: bool = False 140 #: Defects in the range's own metadata that no other surface reports 141 #: (a ``requires`` cycle, a ``requires`` naming nothing). 142 notes: list[str] = Field(default_factory=list)
A range's topology and kill chain, for the people running it.
⚠️ Derived, never carried. Every field is read off the
challenge's own metadata.yaml and docker-compose.yml, which
the deployment already has for every benchmark in the corpus. A
generator emitting a sidecar plan file would have covered only
generated ranges — leaving the hand-built ones, which are the reason
the format exists, with no view at all — and would have been a
second copy of facts the compose already carries, free to disagree
with the estate that actually boots.
⚠️ No groundtruth here. This model says what a milestone is and where it lives, never what it is worth. The value of a minted answer is a per-instance fact and lives on the instance route, where reading it is an audited act.
722class ChallengeBuildKickoffNodeResult(CtfyModel): 723 """One node's response to a build kickoff (single or all). 724 725 ``queued`` is populated only on the ``build-all`` fan-out; for the 726 single-challenge variant the platform inspects ``status`` to learn 727 what the node accepted. ``""`` is *the node did not say* — the 728 bulk fan-out, or a node that could not be reached — which is a 729 different fact from any build status and so is its own member. 730 """ 731 732 node_id: str 733 ok: bool 734 status: ChallengeBuildStatus | Literal[""] = "" 735 queued: list[str] = Field(default_factory=list) 736 skipped_built: list[str] = Field(default_factory=list) 737 skipped_in_progress: list[str] = Field(default_factory=list) 738 error: str = ""
One node's response to a build kickoff (single or all).
queued is populated only on the build-all fan-out; for the
single-challenge variant the platform inspects status to learn
what the node accepted. "" is the node did not say — the
bulk fan-out, or a node that could not be reached — which is a
different fact from any build status and so is its own member.
741class ChallengeBuildKickoffResponse(CtfyModel): 742 """Admin ``POST /admin/challenges/{id}/build`` and ``…/build-all`` response.""" 743 744 challenge_id: str = "" # empty for build-all 745 nodes: list[ChallengeBuildKickoffNodeResult] = Field(default_factory=list)
Admin POST /admin/challenges/{id}/build and …/build-all response.
658class ChallengeBuildNodeState(CtfyModel): 659 """One worker node's view of a single challenge's build state. 660 661 ``node_error`` is non-empty only when the platform could not reach 662 the node at all (heartbeat stale / 5xx) — in that case ``status`` is 663 forced to ``unbuilt`` for the aggregate. 664 """ 665 666 node_id: str 667 status: ChallengeBuildStatus = ChallengeBuildStatus.UNBUILT 668 built_at: float = 0.0 669 error: str = "" 670 node_error: str = ""
One worker node's view of a single challenge's build state.
node_error is non-empty only when the platform could not reach
the node at all (heartbeat stale / 5xx) — in that case status is
forced to unbuilt for the aggregate.
694class ChallengeBuildStateResponse(CtfyModel): 695 """Admin ``GET /admin/challenges/build-state`` response.""" 696 697 rows: list[ChallengeBuildStateRow] = Field(default_factory=list)
Admin GET /admin/challenges/build-state response.
673class ChallengeBuildStateRow(CtfyModel): 674 """Per-challenge build state aggregated across every online node. 675 676 ``aggregated`` rolls up ``nodes`` using a worst-case rule so the 677 table's single status column behaves intuitively: 678 679 * any ``building`` → ``building`` (yellow) 680 * any ``failed`` → ``failed`` (red) 681 * any ``unbuilt`` → ``unbuilt`` (grey) 682 * else → ``built`` (green) 683 684 A node that couldn't be reached at all contributes ``unbuilt`` so 685 the aggregate stays conservative — the admin still sees a 686 "missing" pip and can drill into the modal to find out why. 687 """ 688 689 challenge_id: str 690 aggregated: ChallengeBuildStatus = ChallengeBuildStatus.UNBUILT 691 nodes: list[ChallengeBuildNodeState] = Field(default_factory=list)
Per-challenge build state aggregated across every online node.
aggregated rolls up nodes using a worst-case rule so the
table's single status column behaves intuitively:
- any
building→building(yellow) - any
failed→failed(red) - any
unbuilt→unbuilt(grey) - else →
built(green)
A node that couldn't be reached at all contributes unbuilt so
the aggregate stays conservative — the admin still sees a
"missing" pip and can drill into the modal to find out why.
569class ChallengeFacetCount(CtfyModel): 570 """One bucket on the challenge catalog facet summary. 571 572 ``value`` is the difficulty / tag string; ``total`` is how many 573 challenges in scope carry it. ``solved`` is the *calling* user's 574 solved count in that bucket (across every team they have been on); 575 it is 0 for anonymous callers and for tag buckets (the UI only 576 renders a solved/total ratio for difficulty). 577 """ 578 579 value: str 580 total: int = 0 581 solved: int = 0
One bucket on the challenge catalog facet summary.
value is the difficulty / tag string; total is how many
challenges in scope carry it. solved is the calling user's
solved count in that bucket (across every team they have been on);
it is 0 for anonymous callers and for tag buckets (the UI only
renders a solved/total ratio for difficulty).
584class ChallengeFacets(CtfyModel): 585 """Catalog aggregates for the Challenges page side panels + filter 586 pill counts — served by ``GET /challenges/facets`` so the page no 587 longer derives them from a bulk fetch of the whole catalog. 588 589 Scoped by ``competition_id`` (when given) exactly like the list 590 endpoint, so the pills/charts reflect the same subset the paged 591 list pages through. 592 """ 593 594 total: int = 0 595 difficulty: list[ChallengeFacetCount] = Field(default_factory=list) 596 # Per-category counts (only categories with ≥1 challenge in scope 597 # show up). Ordered by the canonical category sequence so the 598 # filter pills always render web → pwn → reverse → crypto → misc. 599 category: list[ChallengeFacetCount] = Field(default_factory=list) 600 tags: list[ChallengeFacetCount] = Field(default_factory=list) 601 # Per-status counts for the calling caller (``solved`` / ``unsolved`` 602 # / ``running``). Drives the count chip on each status filter pill 603 # so the page doesn't N+1 a status-filtered list per pill. Buckets 604 # are always present (in canonical order) for layout stability; on 605 # anonymous callers every count is 0 — never leaking another 606 # team's state. 607 status: list[ChallengeFacetCount] = Field(default_factory=list) 608 # Per-source-bucket counts — the prefix of a challenge id 609 # (``CVE-2021-1234`` → ``CVE``), i.e. the benchmark suite an item 610 # was ingested from. Ordered by count descending then id, so a 611 # caller rendering a top-N breakdown gets a stable, meaningful 612 # slice order without re-sorting. ``solved`` is always 0 here: 613 # this facet answers "what is the catalog made of", not "how am I 614 # doing", and the landing page that consumes it is anonymous. 615 buckets: list[ChallengeFacetCount] = Field(default_factory=list)
Catalog aggregates for the Challenges page side panels + filter
pill counts — served by GET /challenges/facets so the page no
longer derives them from a bulk fetch of the whole catalog.
Scoped by competition_id (when given) exactly like the list
endpoint, so the pills/charts reflect the same subset the paged
list pages through.
816class ChallengeFlagStats(CtfyModel): 817 """Per-flag aggregate for one challenge (for the detail page).""" 818 819 flag_id: str 820 solves_count: int = 0
Per-flag aggregate for one challenge (for the detail page).
419class ChallengeInfo(CtfyModel): 420 id: str 421 name: str 422 category: ChallengeCategory = ChallengeCategory.WEB 423 difficulty: str = "" 424 description: str = "" 425 # Full-challenge solves: teams that captured every declared 426 # question (across all modes — dynamic + static + select). 427 solves_count: int = 0 428 # Times this challenge has been instantiated, ever — one per 429 # archived InstanceRecord (every terminal path writes one). A 430 # "how much has this been run" signal for the challenge card; 431 # platform-wide, not scoped to the viewing team or competition. 432 launch_count: int = 0 433 tags: list[str] = Field(default_factory=list) 434 # Every question declared on this challenge, in author-declared 435 # order. Single-question challenges have a single entry with 436 # id ``"flag"``. ``QuestionPublicInfo`` exposes prompt/mode/choices 437 # but never the answer. 438 questions: list[QuestionPublicInfo] = Field(default_factory=list) 439 # Per-question solve counts (how many teams captured each 440 # question). Keyed by question id. Useful for the challenge 441 # detail page's progress breakdown. Counts span every mode; 442 # the multi_select grader records one solve per fully-correct 443 # submission, not per chosen choice. 444 flag_solves: dict[str, int] = Field(default_factory=dict) 445 # Files shipped under the challenge's ``attachments/`` directory, 446 # downloadable via ``GET /challenges/{id}/attachments/{name}``. 447 # Empty for pure-network challenges that ship nothing. 448 attachments: list[AttachmentInfo] = Field(default_factory=list) 449 # True for pure question-answer challenges (``category: misc``, 450 # no ``docker-compose.yml`` on disk, every declared question 451 # carries a static ``answer:``). The frontend keys off this flag 452 # to switch the competition surface to a quiz UI and skip the 453 # launch-confirm flow. Source of truth lives in 454 # :attr:`ctfy.core.challenge.ChallengeSpec.is_qa_only`, ultimately 455 # derived from :attr:`BenchmarkContext.is_qa_only`. 456 is_qa_only: bool = False 457 # Set for AWD+ challenges; ``None`` for every other kind. Same role 458 # as ``is_qa_only`` one field up — the flag a client keys off to 459 # switch surface — but an object rather than a bool because a 460 # defence surface needs to know *what* it may send, not only that it 461 # may send something. 462 defence: DefenceInfo | None = None 463 # ---- viewer-scoped: how far in is the caller ----------------------- 464 # 465 # Populated only for an authenticated request; a guest gets the 466 # empty/False sentinels, which is what the server can honestly say 467 # about somebody it never looked up. Scoped by ``competition_id`` 468 # exactly like the tallies above — unscoped they span every team the 469 # caller has been on, which is the standalone catalog's own 470 # platform-wide question and the wrong answer to "have I solved it 471 # *here*". 472 # 473 # ⚠️ **Two subjects, because the platform has two and they disagree 474 # on any roster with more than one human.** Every scoring surface 475 # here means the *team*: ``solves_count`` groups by ``team_id``, 476 # ``InstanceQuestionInfo.answered_correctly`` reads 477 # ``solved_flag_ids(team_id, …)``, ``QaChallengeView`` reads 478 # ``list_solves(team_id=…)``. ``/me/solves`` means the *person* 479 # (``Solve.user_id``, stamped at submit time), which is right for a 480 # profile page and was what the challenge card had been reading — so 481 # a team-mate who had not personally submitted saw 未解出 on a 482 # challenge their team already owned and could not re-earn. 483 # 484 # ⚠️ **A badge should render ``my_team_*``.** That is the subject the 485 # board, the attempt cap and first blood all use; ``my_*`` is the 486 # personal contribution split, and reading it as "solved" is the bug 487 # these four fields were added to end. 488 # 489 # ⚠️ Empty under a silent exercise, never a partial truth: §4b's 490 # channel 2 is the player's own verdict, and ``own_progress_hidden`` 491 # is the gate. The id lists are subsets of what the caller's team 492 # captured, so they can only name questions that team has already 493 # unlocked — see ``projections.viewer_progress``. 494 #: Question ids **this person** captured. ⊆ ``my_team_solved_question_ids``. 495 my_solved_question_ids: list[str] = Field(default_factory=list) 496 #: Whether this person alone captured every declared question. 497 my_fully_solved: bool = False 498 #: Question ids **the caller's team** captured — the scoring subject. 499 my_team_solved_question_ids: list[str] = Field(default_factory=list) 500 #: Whether the caller's team captured every declared question. This 501 #: is the one a "solved" badge means. 502 my_team_fully_solved: bool = False 503 #: Whether the caller's team wrote this challenge — in a 504 #: peer-authoring event, whether solving it can pay them anything. 505 #: 506 #: ⚠️ **The fact existed and reached the player only after the 507 #: work.** `record_correct_answer` writes no solve row for an 508 #: author, and `SubmissionResponse.unranked_reason` says why — on 509 #: the *submission*, which is to say after they opened an instance, 510 #: solved the challenge and pressed submit. Measured on the real 511 #: route: an author's own upload and a stranger's came back from 512 #: `GET /competitions/{id}/challenges` with identical fields and 513 #: nothing about authorship anywhere. `unranked_reason`'s own 514 #: comment already called that moment a dead end — the copy under 515 #: ``own_authored`` is "advice that can never pay off". 516 #: 517 #: ⚠️ **``my_team_*``, deliberately, for its neighbours' reason.** 518 #: The board's subject is the team, so the question this answers is 519 #: *can my team score on this* — not *did I personally upload it*, 520 #: which would leave a captain's team-mates unwarned about a 521 #: challenge their own squad wrote. 522 #: 523 #: ⚠️ **Marks, never hides.** An author must be able to launch and 524 #: submit — it is the only way to check from outside that the 525 #: challenge they uploaded is answerable at all, which is 526 #: `authored_scoring`'s stated requirement rather than a 527 #: concession. Filtering these out of the listing would make the 528 #: one entrant who can fix a broken challenge the one who cannot 529 #: reach it. 530 #: 531 #: ⚠️ Defaults **False**, the quiet direction: a producer that did 532 #: not ask, or an older server, must not tell a team a challenge is 533 #: theirs and cost them one they could have scored on. The other 534 #: default's failure is silent and expensive; this one's is the 535 #: state before this field existed. 536 my_team_authored: bool = False 537 #: Who wrote this challenge, for a player-uploaded one whose author 538 #: asked to be named (plan P2.5) — their display name, never an 539 #: address — and ``""`` for everything else: the corpus, an upload 540 #: credited anonymously, an author with no display name, one whose 541 #: entry in the event they uploaded to is withheld from this viewer, 542 #: and one a competition this viewer can see is showing while it is 543 #: still live (#1927 — the name arrives when the last one ends). 544 #: 545 #: ⚠️ **Written by one function and nowhere else** 546 #: (`ctfy.server.anonymity.anonymise_author_credits`), and the 547 #: projection leaves it blank — so a route that forgets to ask names 548 #: nobody, the direction that fails closed. Every upload comes from an 549 #: entrant, so a name here also says *they entered that event*, and 550 #: that event's anonymity policy decides it before the grant does. 551 author_display_name: str = "" 552 #: The same author's account id, set exactly where the name is and by 553 #: the same function — what the detail page links to their profile's 554 #: list of uploads with. ``""`` wherever the name is. 555 author_user_id: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
793class ChallengePullKickoffNodeResult(CtfyModel): 794 """One node's response to a pull kickoff (single or all). 795 796 ``status`` reads the same way as the build-side twin: ``""`` is *the 797 node did not say*, not a pull status. 798 """ 799 800 node_id: str 801 ok: bool 802 status: ChallengePullStatus | Literal[""] = "" 803 queued: list[str] = Field(default_factory=list) 804 skipped_pulled: list[str] = Field(default_factory=list) 805 skipped_in_progress: list[str] = Field(default_factory=list) 806 error: str = ""
One node's response to a pull kickoff (single or all).
status reads the same way as the build-side twin: "" is the
node did not say, not a pull status.
809class ChallengePullKickoffResponse(CtfyModel): 810 """Admin ``POST /admin/challenges/{id}/pull`` and ``…/pull-all`` response.""" 811 812 challenge_id: str = "" # empty for pull-all 813 nodes: list[ChallengePullKickoffNodeResult] = Field(default_factory=list)
Admin POST /admin/challenges/{id}/pull and …/pull-all response.
755class ChallengePullNodeState(CtfyModel): 756 """One worker node's view of a single challenge's pull state. 757 758 ``node_error`` is non-empty only when the platform could not reach 759 the node at all — ``status`` is then forced to ``unpulled`` for the 760 aggregate. 761 """ 762 763 node_id: str 764 status: ChallengePullStatus = ChallengePullStatus.UNPULLED 765 pulled_at: float = 0.0 766 error: str = "" 767 node_error: str = ""
One worker node's view of a single challenge's pull state.
node_error is non-empty only when the platform could not reach
the node at all — status is then forced to unpulled for the
aggregate.
787class ChallengePullStateResponse(CtfyModel): 788 """Admin ``GET /admin/challenges/pull-state`` response.""" 789 790 rows: list[ChallengePullStateRow] = Field(default_factory=list)
Admin GET /admin/challenges/pull-state response.
770class ChallengePullStateRow(CtfyModel): 771 """Per-challenge pull state aggregated across every online node. 772 773 ``aggregated`` rolls up ``nodes`` worst-case, mirroring the build 774 row: 775 776 * any ``pulling`` → ``pulling`` (yellow) 777 * any ``failed`` → ``failed`` (red) 778 * any ``unpulled`` → ``unpulled`` (grey) 779 * else → ``pulled`` (green) 780 """ 781 782 challenge_id: str 783 aggregated: ChallengePullStatus = ChallengePullStatus.UNPULLED 784 nodes: list[ChallengePullNodeState] = Field(default_factory=list)
Per-challenge pull state aggregated across every online node.
aggregated rolls up nodes worst-case, mirroring the build
row:
- any
pulling→pulling(yellow) - any
failed→failed(red) - any
unpulled→unpulled(grey) - else →
pulled(green)
618class ChallengeRescanNodeResult(CtfyModel): 619 """Outcome of fanning the rescan out to one worker node. 620 621 ``ok`` is False for an offline node (skipped, never contacted) or a 622 node that errored; ``total`` is the node's post-rescan challenge 623 count when reached, else ``None``. One unreachable node never fails 624 the whole operation — the platform rescan still stands. 625 """ 626 627 node_id: str 628 ok: bool 629 total: int | None = None 630 # ⚠️ ``""``, not ``None``. This is the producer of the ``null`` that 631 # ten durable task rows carry and that ``TaskNodeLine`` then refused 632 # — one 500 for the whole admin task list. Absent and empty assert 633 # the same thing for an error string, unlike ``total`` above where 634 # ``None`` means "this producer has no such concept". 635 error: str = ""
638class ChallengeRescanResult(CtfyModel): 639 """Admin ``POST /admin/challenges/rescan`` response. 640 641 ``total`` / ``added`` / ``removed`` describe the platform's own 642 catalog after re-scanning ``challenges_dir``; ``nodes`` carries the 643 per-worker fan-out outcome so a newly added challenge is confirmed 644 launchable, not just listable. 645 """ 646 647 total: int 648 added: list[str] = Field(default_factory=list) 649 removed: list[str] = Field(default_factory=list) 650 nodes: list[ChallengeRescanNodeResult] = Field(default_factory=list)
Admin POST /admin/challenges/rescan response.
total / added / removed describe the platform's own
catalog after re-scanning challenges_dir; nodes carries the
per-worker fan-out outcome so a newly added challenge is confirmed
launchable, not just listable.
837class ChallengeSolveAttempt(CtfyModel): 838 """One archived instance for a challenge — used to visualise per-team 839 multi-solve attempts on the challenge detail page.""" 840 841 instance_id: str 842 team_id: str = "" 843 team_name: str = "" 844 display_name: str = "" 845 challenge_id: str = "" 846 started_at: float = 0.0 847 stopped_at: float = 0.0 848 duration_s: float = 0.0 849 attempts: int = 0 850 solved: bool = False 851 solved_flags: list[str] = Field(default_factory=list) 852 stop_reason: str = "" 853 #: See :attr:`ScoreboardEntry.anonymous`. 854 anonymous: bool = False
One archived instance for a challenge — used to visualise per-team multi-solve attempts on the challenge detail page.
873class ChallengeSolveAttemptsResponse(CtfyModel): 874 challenge_id: str 875 attempts: list[ChallengeSolveAttempt] = Field(default_factory=list) 876 per_team: list[ChallengeTeamSolveSummary] = Field(default_factory=list) 877 # Total archived instances (incl. unsolved) — lets the UI show 878 # "showing N solved of M total" when the response is filtered. 879 total_attempts: int = 0
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
823class ChallengeStats(CtfyModel): 824 challenge_id: str 825 name: str = "" 826 category: ChallengeCategory | None = None 827 difficulty: str = "" 828 # Teams that captured every declared flag on this challenge. 829 solves_count: int = 0 830 total_attempts: int = 0 831 success_rate: float = 0.0 832 # Per-flag breakdown. Single-flag challenges have a single entry with 833 # ``flag_id == "flag"`` and ``solves_count == solves_count`` above. 834 flag_stats: list[ChallengeFlagStats] = Field(default_factory=list)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
857class ChallengeTeamSolveSummary(CtfyModel): 858 """Per-team aggregate for the challenge detail solve-attempts panel.""" 859 860 team_id: str 861 team_name: str = "" 862 display_name: str = "" 863 solve_count: int = 0 864 attempt_count: int = 0 865 best_duration_s: float = 0.0 866 total_duration_s: float = 0.0 867 #: See :attr:`ScoreboardEntry.anonymous`. 868 anonymous: bool = False 869 first_solved_at: float = 0.0 870 last_solved_at: float = 0.0
Per-team aggregate for the challenge detail solve-attempts panel.
1272class ChallengeValuePoint(CtfyModel): 1273 """One point on a challenge's decay curve. 1274 1275 ⚠️ ``solvers`` is **1-based**, matching the decay arithmetic 1276 everywhere else: the first team to capture sees the full base value 1277 under every curve, which is the property that makes the curves 1278 comparable to each other and to a static board. 1279 """ 1280 1281 solvers: int = 0 1282 value: int = 0
One point on a challenge's decay curve.
⚠️ solvers is 1-based, matching the decay arithmetic
everywhere else: the first team to capture sees the full base value
under every curve, which is the property that makes the curves
comparable to each other and to a static board.
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.
598class CheckAnswerRequest(CtfyModel): 599 """Run the checker for one *checker-mode* question on an instance. 600 601 Proof-of-exploitation: the platform execs the question's 602 author-supplied checker inside the trusted judge sidecar; the player 603 submits no answer string — the sidecar emits the minted token only 604 when the exploit's effect is verified. 605 """ 606 607 # Which checker question to verify. Checker questions usually carry a 608 # descriptive id (``"deface"`` / ``"persist"`` / …); defaults to 609 # ``"flag"`` for the single-question shape. 610 question_id: str = "flag"
Run the checker for one checker-mode question on an instance.
Proof-of-exploitation: the platform execs the question's author-supplied checker inside the trusted judge sidecar; the player submits no answer string — the sidecar emits the minted token only when the exploit's effect is verified.
613class CheckAnswerResponse(CtfyModel): 614 # True when the checker confirmed the exploit (its stdout matched the 615 # instance's minted token for this question) and a solve was recorded. 616 passed: bool 617 # Echoes the question id on a pass; ``None`` otherwise. 618 question_id: str | None = None 619 # Checker process exit code — diagnostic only; the token match is the 620 # real signal. ``None`` when the checker couldn't be run at all. 621 exit_code: int | None = None 622 # True when the checker exceeded its wall-clock budget. 623 timed_out: bool = False 624 # True when the team had already solved this question (short-circuit, 625 # no node call made). 626 already_solved: bool = False 627 # First-blood rank on a fresh solve (1/2/3 = 1st/2nd/3rd blood), 0 628 # otherwise. Mirrors ``SubmissionResponse.solve_rank``. 629 solve_rank: int = 0 630 # True when this check completed the challenge (every question solved). 631 challenge_fully_solved: bool = False 632 # Server clock (epoch seconds) when the check ran, for the UI's 633 # "last checked" line. 634 last_checked_at: float = 0.0
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
527class ClusterInfo(CtfyModel): 528 """Returned by ``GET /cluster-info``. 529 530 Provides the bits the ``ctfy-admin server invite`` CLI and admin UI wizard 531 need to build a runnable join command. The *registration token* is 532 minted separately via ``POST /nodes/invites`` — it isn't on this 533 payload because cluster info is queried speculatively by the UI on 534 every admin dashboard load, and we never want to hand an invite 535 token to a page that didn't explicitly ask for one. 536 """ 537 538 platform_url: str 539 node_image: str
Returned by GET /cluster-info.
Provides the bits the ctfy-admin server invite CLI and admin UI wizard
need to build a runnable join command. The registration token is
minted separately via POST /nodes/invites — it isn't on this
payload because cluster info is queried speculatively by the UI on
every admin dashboard load, and we never want to hand an invite
token to a page that didn't explicitly ask for one.
1327class CompetitionAdminInfo(CtfyModel): 1328 """One per-competition admin grant, for the super-admin management 1329 card on the competition detail page.""" 1330 1331 user_id: str 1332 display_name: str = "" 1333 email: str = "" 1334 role: CompetitionGrantRole = "admin" 1335 granted_by: str = "" 1336 granted_at: datetime | None = None
One per-competition admin grant, for the super-admin management card on the competition detail page.
1508class CompetitionApplicationInfo(CtfyModel): 1509 """The caller's own application to one competition (plan L2). 1510 1511 ⚠️ **No ``decided_by``.** The applicant is told the verdict and the 1512 note, never which organiser ruled: a name on a rejection is somebody 1513 to argue with, and the organisers answer as the event. 1514 """ 1515 1516 competition_id: str 1517 message: str = "" 1518 status: ReviewStatus = "pending" 1519 created_at: datetime 1520 #: When an organiser ruled — ``None`` while pending. 1521 decided_at: datetime | None = None 1522 #: What the organisers wrote with a rejection. ``""`` is none. 1523 decision_note: str = ""
The caller's own application to one competition (plan L2).
⚠️ No decided_by. The applicant is told the verdict and the
note, never which organiser ruled: a name on a rejection is somebody
to argue with, and the organisers answer as the event.
1498class CompetitionApplicationRequest(CtfyModel): 1499 """Body for ``PUT /competitions/{id}/application`` (plan L2). 1500 1501 ``message`` is what the organisers read in their queue — who you are, 1502 why this event. Optional: the application itself is the request. 1503 """ 1504 1505 message: str = Field(default="", max_length=APPLICATION_MESSAGE_MAX)
Body for PUT /competitions/{id}/application (plan L2).
message is what the organisers read in their queue — who you are,
why this event. Optional: the application itself is the request.
1555class CompetitionApplicationReviewRequest(CtfyModel): 1556 """Body for ``POST /admin/competitions/{id}/applications/{user_id}/review``. 1557 1558 ``note`` goes to the applicant with a rejection, and a rejection 1559 needs one — it is final, and "no" with nothing beside it is a support 1560 ticket. An approval takes none: its answer is the invitation mail. 1561 """ 1562 1563 status: ApplicationVerdict 1564 note: str = Field(default="", max_length=APPLICATION_MESSAGE_MAX)
Body for POST /admin/competitions/{id}/applications/{user_id}/review.
note goes to the applicant with a rejection, and a rejection
needs one — it is final, and "no" with nothing beside it is a support
ticket. An approval takes none: its answer is the invitation mail.
1526class CompetitionApplicationRow(CtfyModel): 1527 """One application in an event's queue, for its organisers (plan L2). 1528 1529 ``display_name`` / ``email`` are the invitations roster's pair, 1530 because the reader is deciding whether to let this person in. 1531 """ 1532 1533 competition_id: str 1534 user_id: str 1535 display_name: str = "" 1536 email: str = "" 1537 message: str = "" 1538 status: ReviewStatus = "pending" 1539 created_at: datetime 1540 decided_by: str = "" 1541 #: Resolved from :attr:`decided_by`, because the raw id names nobody. 1542 #: ``""`` while pending, when the organiser's account is gone, and 1543 #: when an address the person proved answered it (the claim has no 1544 #: organiser of its own beyond the one who invited the address). 1545 decided_by_name: str = "" 1546 decided_at: datetime | None = None 1547 decision_note: str = "" 1548 #: Whether the person holds an invitation **now**. Distinct from 1549 #: ``status == "approved"``: an invitation revoked after approval 1550 #: leaves the verdict standing and this false, and a rejected 1551 #: applicant invited directly reads ``rejected`` beside ``True``. 1552 invited: bool = False
One application in an event's queue, for its organisers (plan L2).
display_name / email are the invitations roster's pair,
because the reader is deciding whether to let this person in.
1172class CompetitionAttachmentInfo(CtfyModel): 1173 """One file hung on a competition — its rules, handbook or notice (#1808). 1174 1175 ⚠️ **No bytes and no object key.** The bytes are fetched from 1176 ``url``, proxied through the platform behind the competition's own 1177 visibility — the key is exactly the guess that proxying exists to 1178 make useless. 1179 """ 1180 1181 id: str 1182 competition_id: str 1183 #: Sanitised, display only — the object key is derived from ``id``. 1184 filename: str 1185 #: Sniffed from the bytes. An ``image/*`` type renders inline; every 1186 #: other type is a download. 1187 content_type: str 1188 size_bytes: int 1189 #: ``""`` shows to every reader; a locale tag shows only to readers of 1190 #: that language. The browser filters — the language is a client-side 1191 #: preference switched without a reload. 1192 locale: str = "" 1193 #: Optional display title per locale; ``{}`` means show ``filename``. 1194 title: LocalizedText = Field(default_factory=dict) 1195 position: int = 0 1196 uploaded_at: datetime | None = None 1197 1198 @classmethod 1199 def of(cls, row: CompetitionAttachmentState) -> CompetitionAttachmentInfo: 1200 """The projection of a stored row — one spelling for every reader 1201 (the list routes, the detail payload), so none drops a field.""" 1202 return cls( 1203 id=row.id, 1204 competition_id=row.competition_id, 1205 filename=row.filename, 1206 content_type=row.content_type, 1207 size_bytes=row.size_bytes, 1208 locale=row.locale, 1209 title=row.title, 1210 position=row.position, 1211 uploaded_at=row.uploaded_at, 1212 ) 1213 1214 @computed_field 1215 @property 1216 def url(self) -> str: 1217 """The stable address of the bytes, for linking from the handbook 1218 markdown: ``/api/v1/competitions/{id}/attachments/{attachment_id}``. 1219 Derived from the two ids, so it cannot disagree with the route.""" 1220 return f"/api/v1/competitions/{self.competition_id}/attachments/{self.id}"
One file hung on a competition — its rules, handbook or notice (#1808).
⚠️ No bytes and no object key. The bytes are fetched from
url, proxied through the platform behind the competition's own
visibility — the key is exactly the guess that proxying exists to
make useless.
1198 @classmethod 1199 def of(cls, row: CompetitionAttachmentState) -> CompetitionAttachmentInfo: 1200 """The projection of a stored row — one spelling for every reader 1201 (the list routes, the detail payload), so none drops a field.""" 1202 return cls( 1203 id=row.id, 1204 competition_id=row.competition_id, 1205 filename=row.filename, 1206 content_type=row.content_type, 1207 size_bytes=row.size_bytes, 1208 locale=row.locale, 1209 title=row.title, 1210 position=row.position, 1211 uploaded_at=row.uploaded_at, 1212 )
The projection of a stored row — one spelling for every reader (the list routes, the detail payload), so none drops a field.
1214 @computed_field 1215 @property 1216 def url(self) -> str: 1217 """The stable address of the bytes, for linking from the handbook 1218 markdown: ``/api/v1/competitions/{id}/attachments/{attachment_id}``. 1219 Derived from the two ids, so it cannot disagree with the route.""" 1220 return f"/api/v1/competitions/{self.competition_id}/attachments/{self.id}"
The stable address of the bytes, for linking from the handbook
markdown: /api/v1/competitions/{id}/attachments/{attachment_id}.
Derived from the two ids, so it cannot disagree with the route.
1252class CompetitionAttachmentUpdate(CtfyModel): 1253 """An organiser's edit to one attachment. Partial: an omitted field is 1254 left alone (``model_fields_set``), so re-titling does not reset the 1255 locale — the defect the token editor already paid for.""" 1256 1257 #: ``{}`` clears the title back to the filename. 1258 title: LocalizedText | None = None 1259 locale: str | None = None 1260 position: int | None = Field(default=None, ge=0, le=100_000) 1261 1262 _v_title = field_validator("title", mode="before")( 1263 lambda v: localized_field(v, cap=MAX_ATTACHMENT_TITLE_CHARS, field="title") 1264 ) 1265 _v_locale = field_validator("locale", mode="before")(attachment_locale)
An organiser's edit to one attachment. Partial: an omitted field is
left alone (model_fields_set), so re-titling does not reset the
locale — the defect the token editor already paid for.
1223class CompetitionAttachmentsResponse(CtfyModel): 1224 """A competition's files, in display order — every locale's. 1225 1226 ⚠️ **Unfiltered by language on purpose.** Which files a reader sees 1227 is their language, a browser preference switched without a reload; 1228 a server-side filter would follow ``Accept-Language`` and not the 1229 toggle — ``resolve_localized``'s reason for resolving in the client. 1230 """ 1231 1232 competition_id: str 1233 attachments: list[CompetitionAttachmentInfo] = Field(default_factory=list)
A competition's files, in display order — every locale's.
⚠️ Unfiltered by language on purpose. Which files a reader sees
is their language, a browser preference switched without a reload;
a server-side filter would follow Accept-Language and not the
toggle — resolve_localized's reason for resolving in the client.
1347class CompetitionChallengeBreakdown(CtfyModel): 1348 challenges: list[CompetitionChallengeRow] = Field(default_factory=list) 1349 generated_at: float = 0.0
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
1285class CompetitionChallengeRow(CtfyModel): 1286 """Per-challenge competition aggregate: solve count, attempt 1287 success rate, and first blood. Reconstructed from the solve log 1288 with the same window / team filter as the scoreboard so the 1289 numbers always agree with the leaderboard.""" 1290 1291 challenge_id: str 1292 name: str = "" 1293 category: ChallengeCategory | None = None 1294 difficulty: str = "" 1295 # Teams that captured every declared flag (matches scoreboard's 1296 # ``solved`` tally). 1297 solves_count: int = 0 1298 # In-window submissions for this challenge across registered teams. 1299 attempts: int = 0 1300 success_rate: float = 0.0 1301 # What one flag of this challenge is worth **right now**, under this 1302 # competition's own scoring rule, at the solver count above. 1303 # 1304 # ⚠️ **``None`` means the board does not price challenges, and it is 1305 # not ``0``.** On a ``flag_count`` board the warm-up and the last 1306 # link of a five-stage chain are both worth one flag, so there is no 1307 # price to publish — and a zero would read as "this challenge is 1308 # worthless", a claim about the competition nobody made. The panel 1309 # that reads this is meant to be *absent* there, not full of zeroes. 1310 # 1311 # ⚠️ Server-composed for the reason every axis on `CompetitionInfo` 1312 # is: the arithmetic is ``PointsRule._decayed``, and a client that 1313 # recomputed decay would be the copy a reader is looking at when the 1314 # two disagree — on the one board whose whole point is that the 1315 # numbers move while nobody solves anything. 1316 value: int | None = None 1317 # The same flag's value at one solver, so a panel can show how far it 1318 # has fallen without re-deriving the curve. ⚠️ Absent on the same 1319 # terms as ``value``: two fields, one question. 1320 base_value: int | None = None 1321 #: ``(solvers, value)`` from the first capture until the price stops 1322 #: moving — the shape between ``base_value`` and ``value``. 1323 #: 1324 #: ⚠️ **Sampled through the scoring rule, never re-derived.** Drawing 1325 #: this from the decay config in a panel would be a second 1326 #: implementation of ``PointsRule._decayed`` and the copy a reader is 1327 #: looking at when the two disagree. 1328 #: 1329 #: ⚠️ **Empty on a board that does not price**, matching ``value``'s 1330 #: ``None`` — never a flat line at zero, which would be a price 1331 #: nobody set. A board with no *decay* is a flat line at the base, 1332 #: which is two points rather than none. 1333 decay: list[ChallengeValuePoint] = Field(default_factory=list) 1334 # First team to solve this challenge in-window. Empty when unsolved. 1335 first_blood_team_id: str = "" 1336 first_blood_team_name: str = "" 1337 first_blood_at: datetime | None = None 1338 # ⚠️ Named for the *attribution*, not for the row: every other field 1339 # here is a fact about the challenge, and a bare ``anonymous`` on 1340 # this model would read as "this challenge is anonymous". Empty 1341 # ``first_blood_team_id`` with this ``False`` is an unsolved 1342 # challenge; empty with it ``True`` would be a contradiction, so the 1343 # hidden case blanks the trio and leaves this alone. 1344 first_blood_anonymous: bool = False
Per-challenge competition aggregate: solve count, attempt success rate, and first blood. Reconstructed from the solve log with the same window / team filter as the scoreboard so the numbers always agree with the leaderboard.
415class CompetitionCreate(CtfyModel): 416 """Request body for ``POST /admin/competitions``. 417 418 Time fields accept ISO 8601 strings; empty means "no bound" (live 419 immediately / never expires). ``challenge_ids`` is validated against 420 the spec registry inside the route handler — unknown ids return 422. 421 """ 422 423 title: str = Field(min_length=1, max_length=200) 424 #: ``{locale: text}``; a bare string is read as the base locale. 425 description: LocalizedText = Field(default_factory=dict) 426 #: The organiser's long-form rules page, markdown. See 427 #: ``CompetitionState.handbook`` — ``description`` stays the blurb 428 #: its four render sites truncate, this is the document. 429 handbook: LocalizedText = Field(default_factory=dict) 430 431 _v_description = field_validator("description", mode="before")( 432 lambda v: localized_field(v, cap=MAX_DESCRIPTION_CHARS, field="description") 433 ) 434 _v_handbook = field_validator("handbook", mode="before")( 435 lambda v: localized_field(v, cap=MAX_HANDBOOK_CHARS, field="handbook") 436 ) 437 starts_at: datetime | None = None 438 ends_at: datetime | None = None 439 # No fixed length cap: the real bound is ``_check_challenge_ids`` in 440 # ``admin_competitions`` (every id must be a known spec, so a valid list 441 # can never exceed the catalog). A magic number here is arbitrary and a 442 # recurring foot-gun as the corpus grows — bucket comps already exceed 1000. 443 challenge_ids: list[str] = Field(default_factory=list) 444 # New competitions start hidden ("draft") so admins can prepare them 445 # privately and publish when ready. 446 status: CompetitionStatus = "draft" 447 # Participation access (orthogonal to ``status``). ``public`` is open 448 # to everyone; ``private_listed`` / ``private_hidden`` require an admin 449 # invite to register (the latter also hides the comp from non-invited 450 # users). Defaults to ``public`` so ad-hoc API callers stay open. 451 access: CompetitionAccess = "public" 452 # Marks this competition as an operator-curated eval **standard set** — 453 # only such a competition may back an ``official`` EvalCampaign 454 # (docs/model-eval-vendor-tenancy.md §4.4). ``corpus_sha`` optionally pins 455 # the benchmark-corpus snapshot it was certified against. Off by default. 456 eval_standard: bool = False 457 corpus_sha: str = "" 458 # May a user enter their own agent here? Its own axis, deliberately 459 # not ``eval_standard`` above — that one means "a curated, 460 # reproducible standard set", which is a claim about the challenge 461 # list, not about who may play. ⚠️ Defaults **open**, unlike every 462 # other marker on this model: an API caller who says nothing gets a 463 # competition agents may enter. See CompetitionState.agents_allowed. 464 agents_allowed: bool = True 465 # Do entrants write challenges for each other here? ⚠️ Defaults 466 # **off**, unlike ``agents_allowed`` directly above and for the 467 # opposite reason: that one says who may enter an event, this one 468 # opens an upload door, and a caller who says nothing must not get 469 # one. See CompetitionState.authoring_enabled. 470 authoring_enabled: bool = False 471 # Does this event's wall draw its agents' model traffic? ⚠️ 472 # ``False`` for a caller who says nothing, `authoring_enabled`'s 473 # reasoning rather than `agents_allowed`'s: this one opens a panel 474 # on a *public, tokenless* projector page, so silence must not open 475 # it. See CompetitionState.agent_pulse. 476 agent_pulse: bool = False 477 # Is the field of entrants dispatched agents, not humans — an agent 478 # exhibition? ⚠️ Defaults **off**, ``authoring_enabled``'s reasoning 479 # rather than ``agents_allowed``'s: this closes the human sign-up 480 # door, so a caller who says nothing must get an ordinary, 481 # human-registerable event. See CompetitionState.entrants_are_agents. 482 entrants_are_agents: bool = False 483 # The authoring window, distinct from both the play window and the 484 # registration one; unset on both ends means "for as long as the 485 # event runs". `core.competitions.authoring_phase` is the rule. 486 authoring_starts_at: datetime | None = None 487 authoring_ends_at: datetime | None = None 488 # Uploads one team may have awaiting review here; ``0`` defers to the 489 # platform's ``max_pending_authored_challenges``, ``max_team_size``'s 490 # shape. See CompetitionState.authoring_pending_per_team. 491 authoring_pending_per_team: int = Field(default=0, ge=0) 492 # Uploads that may await review here at once, every team together; 493 # ``0`` = no capacity. See CompetitionState.authoring_queue_capacity. 494 authoring_queue_capacity: int = Field(default=0, ge=0) 495 # The contributor agreement an upload here must accept: its version 496 # and where to read it, both or neither (``""`` = none asked). See 497 # CompetitionState.authoring_terms_version. 498 authoring_terms_version: str = Field(default="", max_length=64) 499 authoring_terms_url: WebUrl = Field(default="", max_length=500) 500 # Which rule ranks this competition's board, and its tuning. Empty 501 # selects the platform default (``flag_count``), so an API caller 502 # that says nothing gets exactly the historic behaviour. Set it here 503 # rather than later where you can: the rule freezes once the 504 # competition goes live (see ``scoring_locked``). 505 scoring_rule: ScoringRuleName | Literal[""] = "" 506 scoring_params: dict[str, Any] = Field(default_factory=dict) 507 # Silent exercise mode, orthogonal to ``scoring_rule`` on purpose. 508 # ``CompetitionState.silent`` is the derivation. 509 silent: bool = False 510 # Playable slices inside the window. Empty (the default) means the 511 # whole ``starts_at``..``ends_at`` window is playable, which is how 512 # every competition has always behaved. Supplying sessions also 513 # *sets* the window: ``CompetitionState`` derives ``starts_at`` / 514 # ``ends_at`` from the envelope, so an organiser sending sessions 515 # need not compute the outer dates and cannot contradict them. 516 sessions: list[CompetitionSessionInfo] = Field(default_factory=list) 517 # Seconds after the play window closes during which the engagement 518 # report may still be saved. ``0`` (the default) means the report 519 # closes with the event, which is exactly where the play gate closed 520 # it before this field existed. See ``CompetitionState.report_window_s``. 521 report_window_s: int = 0 522 # Whether an entrant may withhold their identity here, and what 523 # withholding looks like. Defaults ``forbidden`` — unlike 524 # ``agents_allowed`` directly above, and for the opposite reason: an 525 # API caller who says nothing must not get an event whose board 526 # fills with pseudonyms nobody chose, and a real-name event (the 527 # population this platform's PII dossier exists for) must be what 528 # you get by default. See ``CompetitionState.anonymous_entry``. 529 anonymous_entry: AnonymityPolicy = "forbidden" 530 anonymous_entry_kind: AnonymityKind = "pseudonymous" 531 # --- registration --------------------------------------------------- 532 # The window sign-ups are open, distinct from the play window above. 533 # ``CompetitionState.registration_starts_at`` is the derivation. 534 registration_starts_at: datetime | None = None 535 registration_ends_at: datetime | None = None 536 # Per-competition roster cap. 0 defers to the platform-wide setting, 537 # so two events on one deployment can carry different rules. 538 max_team_size: int = Field(default=0, ge=0) 539 # When true a newly registered team lands ``pending`` and cannot play 540 # until an organiser approves it. 541 registration_review: bool = False 542 # field name -> policy; anything absent is treated as hidden. 543 # ``CompetitionState.registration_fields`` is the derivation. 544 registration_fields: dict[str, RegistrationFieldPolicy] = Field(default_factory=dict) 545 # Days after ``ends_at`` before the retention sweep blanks the three 546 # sealed columns (real name, phone, postal address). ``0`` keeps them 547 # indefinitely, which is what every competition predating this reads 548 # as — the purge is irreversible, so turning it on is a decision 549 # somebody makes about one event, never a default. 550 pii_retention_days: int = Field(default=0, ge=0) 551 552 @field_validator("registration_fields") 553 @classmethod 554 def _known_registration_fields( 555 cls, v: dict[str, RegistrationFieldPolicy] 556 ) -> dict[str, RegistrationFieldPolicy]: 557 return _check_registration_fields(v) 558 559 _norm_times = field_validator("starts_at", "ends_at", mode="before")(_blank_to_none) 560 561 #: The rounds this competition runs, configured in the same request. 562 #: 563 #: ⚠️ **Optional, and its absence is not "no match" — it is "not 564 #: configured here".** A match is a side table keyed by competition 565 #: id and ``PUT …/awd/match`` is an upsert, so a competition created 566 #: without one can still be given one afterwards; that is how every 567 #: match was made before this field existed. What it removes is the 568 #: *dead end*: the ``awd_classic`` preset used to hand back a 569 #: competition that looked configured and was inert, with the one 570 #: thing that makes it a match reachable only from another page. 571 #: 572 #: ⚠️ **Not derived from ``scoring_rule``.** ``awd`` names how a 573 #: board is *ranked* and says nothing about rounds — 574 #: ``awd_scoreboard`` never reads it (quote ``grep -c scoring_rule 575 #: ctfy/server/awd_scoreboard.py``) — so a competition may rank as 576 #: AWD with no match, and a match may run under any rule. Coupling 577 #: them here would make one axis silently decide the other. 578 awd_match: AwdMatchConfig | None = None
Request body for POST /admin/competitions.
Time fields accept ISO 8601 strings; empty means "no bound" (live
immediately / never expires). challenge_ids is validated against
the spec registry inside the route handler — unknown ids return 422.
1268class CompetitionDetail(CompetitionInfo): 1269 """Full detail payload — includes a *preview* of the resolved 1270 challenge summaries and the caller's registration state when 1271 authenticated. 1272 1273 ⚠️ ``challenges`` is bounded by 1274 :data:`~ctfy.server.routes.competitions.COMPETITION_CHALLENGE_PREVIEW` 1275 and is **not** the competition's challenge set. It used to be, and a 1276 bucket competition put hundreds of full ``ChallengeInfo`` rows — 1277 each carrying questions, attachments and a defence block — through 1278 a payload whose only reader renders six cards. 1279 1280 Anything that means *all of them* must page 1281 ``GET /competitions/{id}/challenges`` (SDK: 1282 ``client.competition(id).challenges(offset, limit)``), and anything 1283 that means *how many* must read ``challenge_count``. Both existed as 1284 ``len(challenges)`` before the bound and would have kept working 1285 while reporting the preview size — which is why the two aggregates 1286 below are served rather than left to the caller to derive. 1287 """ 1288 1289 #: The organiser's long-form rules page, markdown. 1290 #: 1291 #: ⚠️ **On the detail payload and deliberately not on 1292 #: ``CompetitionInfo``.** ``GET /competitions`` pages 50 rows of that 1293 #: model at a time and this field is capped at 20 000 characters, so 1294 #: publishing it there is ``AuthoredChallengeSummary``'s recorded 1295 #: defect — a column projection carrying the blob. The rules page 1296 #: reads it off ``CompetitionContext``, which ``CompetitionLayout`` 1297 #: already fetched, so it costs no request of its own. 1298 handbook: LocalizedText = Field(default_factory=dict) 1299 #: When the handbook last really changed; ``None`` while nobody has 1300 #: written one. Its own column rather than ``updated_at``, which 1301 #: moves whenever any field does — see ``CompetitionState``. 1302 handbook_updated_at: datetime | None = None 1303 #: The organiser's files — rules, handbook, notice (#1808) — in 1304 #: display order and **every locale's**; the page filters to the 1305 #: reader's language. On the detail payload for the handbook's own 1306 #: reason: the rules page reads the context and costs no request. 1307 attachments: list[CompetitionAttachmentInfo] = Field(default_factory=list) 1308 1309 challenges: list[ChallengeInfo] = Field(default_factory=list) 1310 # The true totals, over the whole resolved set rather than the 1311 # preview. Derived here because a client cannot recover them from a 1312 # bounded list, and two clients deriving them differently is two 1313 # clients disagreeing about how big the competition is. 1314 challenge_count: int = 0 1315 difficulty_counts: dict[str, int] = Field(default_factory=dict) 1316 registered_at: datetime | None = None
Full detail payload — includes a preview of the resolved challenge summaries and the caller's registration state when authenticated.
⚠️ challenges is bounded by
~ctfy.server.routes.competitions.COMPETITION_CHALLENGE_PREVIEW
and is not the competition's challenge set. It used to be, and a
bucket competition put hundreds of full ChallengeInfo rows —
each carrying questions, attachments and a defence block — through
a payload whose only reader renders six cards.
Anything that means all of them must page
GET /competitions/{id}/challenges (SDK:
client.competition(id).challenges(offset, limit)), and anything
that means how many must read challenge_count. Both existed as
len(challenges) before the bound and would have kept working
while reporting the preview size — which is why the two aggregates
below are served rather than left to the caller to derive.
309class CompetitionFormatPreset(CtfyModel): 310 """One named starting point for the create-competition wizard. 311 312 ⚠️ **Served rather than mirrored in the frontend, and the reason is 313 ``scoring_params``.** A preset's params have to survive 314 ``build_rule``, which is strict — a client-side copy naming a curve 315 this server does not accept would pass every frontend check and 422 316 the organiser on the wizard's last step, with the cause six screens 317 back. Keeping the catalogue on the side that validates it makes that 318 class of drift impossible rather than tested-for. 319 320 Read-only and unstored: see ``ctfy.core.competition_formats`` for why 321 a competition never records which preset made it. 322 """ 323 324 id: str 325 #: Empty selects the platform default, exactly as it does on 326 #: ``CompetitionCreate`` — see the core module's note on why a 327 #: preset does not spell today's default out. 328 scoring_rule: ScoringRuleName | Literal[""] = "" 329 scoring_params: dict[str, Any] = Field(default_factory=dict) 330 silent: bool = False 331 #: This format is configured in the competition's own match settings 332 #: and does not read ``scoring_rule`` at all. 333 needs_awd_match: bool = False 334 #: The format's deliverable has no deadline until one is set, and 335 #: ``0`` closes the report with the event rather than meaning unset. 336 needs_report_window: bool = False 337 #: Whether this format needs the authoring door open. A board that 338 #: scores what entrants wrote is inert without it — see the core 339 #: dataclass for why this axis is set rather than surfaced. 340 authoring_enabled: bool = False 341 #: Who attacks each round: ``none`` is a melee between the entrants, 342 #: anything else is defence-only. A format axis, not a knob — see 343 #: the core dataclass for what picking the wrong one costs. 344 awd_attacker: AwdAttacker = AwdAttacker.NONE 345 #: The **match's** scoring knobs, not the competition's. Empty means 346 #: the packaged defaults; a defence-only format has to say something 347 #: here or its optimal opening is for a defender to stop their own 348 #: service. ``DEFENCE_ONLY_KNOBS`` carries the arithmetic. 349 awd_scoring_params: dict[str, float] = Field(default_factory=dict) 350 #: Is the field of entrants dispatched agents rather than humans — an 351 #: agent exhibition? The wizard seeds ``entrants_are_agents`` on the 352 #: created competition from this. See the core dataclass. 353 entrants_are_agents: bool = False
One named starting point for the create-competition wizard.
⚠️ Served rather than mirrored in the frontend, and the reason is
scoring_params. A preset's params have to survive
build_rule, which is strict — a client-side copy naming a curve
this server does not accept would pass every frontend check and 422
the organiser on the wizard's last step, with the cause six screens
back. Keeping the catalogue on the side that validates it makes that
class of drift impossible rather than tested-for.
Read-only and unstored: see ctfy.core.competition_formats for why
a competition never records which preset made it.
807class CompetitionInfo(CtfyModel): 808 """Summary row used in the list view. 809 810 Per-viewer fields (``is_registered`` / ``my_*``) are populated only 811 when the request carries an authenticated user; for anonymous 812 requests they fall back to sentinels (empty strings, ``-1``) so 813 the response shape stays stable. Front-end treats ``my_rank < 0`` 814 as "no value" rather than "rank zero". 815 """ 816 817 id: str 818 title: str 819 #: ``{locale: text}`` — resolved in the browser by 820 #: ``lib/localizedText.ts`` so it follows the language toggle 821 #: without a refetch, and server-side by 822 #: ``ctfy.core.localized.resolve_localized`` where there is no 823 #: browser (email, the CLI). 824 description: LocalizedText = Field(default_factory=dict) 825 starts_at: datetime | None = None 826 ends_at: datetime | None = None 827 created_at: datetime | None = None 828 updated_at: datetime | None = None 829 created_by: str 830 created_by_name: str 831 challenge_ids: list[str] = Field(default_factory=list) 832 # Visibility gate. Only ``published`` rows reach non-admins; admins 833 # and per-competition admins also receive ``draft`` / ``archived``. 834 status: CompetitionStatus = "published" 835 # Participation access. ``public`` is open; ``private_listed`` stays 836 # discoverable but invite-only to register; ``private_hidden`` is also 837 # hidden from non-invited users. Default ``public`` for backward 838 # compat (legacy rows / anonymous responses). 839 access: CompetitionAccess = "public" 840 #: The series this contest is an occurrence of, empty for a one-off. 841 #: Carried so a client can reach the **ladder** — before this the id 842 #: was on no wire model, so the whole rating surface was unreachable 843 #: from the browser however public its route was. Not a disclosure: 844 #: it names a series whose ladder the reader may already read, and 845 #: nothing about the series' pool or cadence travels with it. 846 series_id: str = "" 847 # Eval standard-set marker + pinned corpus snapshot (admin-only meaning; 848 # see CompetitionState). Surfaced so the admin form can show/edit it. 849 eval_standard: bool = False 850 corpus_sha: str = "" 851 #: What this board ranks by, in precedence order — the first entry 852 #: is the headline number and the rest are the tie-breaks. 853 #: 854 #: ⚠️ **A property of the rule, so it belongs on the competition 855 #: rather than on the board page.** The rule freezes once the 856 #: competition goes live (``scoring_locked``), so this is 857 #: configuration and not progress; putting it on the paginated board 858 #: would repeat it per page and make page two able to disagree with 859 #: page one. 860 #: 861 #: ⚠️ Empty from a server that predates it, which reads as "no 862 #: explanation offered" rather than "nothing was sorted" — the 863 #: client renders nothing instead of claiming the board is unranked. 864 ordering: list[BoardCriterion] = Field(default_factory=list) 865 #: Whether a user may enter their own agent here. Public, because the 866 #: competition page renders a different thing for each answer and a 867 #: client cannot derive it — ``eval_standard`` is a *different* 868 #: question and reading it as this one is the bug this replaces. 869 #: 870 #: ⚠️ Defaults ``True`` here too, so an older server that does not 871 #: send the field leaves the page offering the door rather than 872 #: taking it away. Gates fail open. 873 agents_allowed: bool = True 874 #: Whether this competition asks entrants to write challenges. 875 #: 876 #: ⚠️ Defaults **False** here, the opposite of ``agents_allowed`` 877 #: directly above, and for the opposite reason. That one is a 878 #: *gate*, so an older server that omits it must leave the door 879 #: open. This one is an *offer*: a client reading a default of 880 #: ``True`` would show every player of every event an authoring 881 #: page that refuses them. Silence must claim nothing, and here 882 #: claiming nothing is ``False``. 883 authoring_enabled: bool = False 884 #: Does this event's wall draw its agents' model traffic? ⚠️ 885 #: Defaults ``False`` on both sides, and the client reads it as the 886 #: scene's one axis — so an older server saying nothing leaves the 887 #: wall exactly as it was rather than growing a panel nobody armed. 888 agent_pulse: bool = False 889 #: Is the field of entrants dispatched agents, not humans — an agent 890 #: exhibition? The browser reads this to replace the "register" CTA 891 #: with a spectate affordance and to badge the card/detail page. 892 #: 893 #: ⚠️ Defaults **False** here, ``authoring_enabled``'s direction. A 894 #: client reading an older server's silence as ``True`` would take 895 #: the sign-up button away from a real, human-registerable event — 896 #: the over-blocking failure. The server still refuses a human on an 897 #: exhibition regardless (``register_for_competition``); this field 898 #: only decides what the page *offers*. See 899 #: CompetitionState.entrants_are_agents. 900 entrants_are_agents: bool = False 901 # The authoring window, distinct from both the play window and the 902 # registration one; unset on both ends means "for as long as the 903 # event runs". `core.competitions.authoring_phase` is the rule. 904 authoring_starts_at: datetime | None = None 905 authoring_ends_at: datetime | None = None 906 #: This competition's own per-team pending cap (``0`` = the 907 #: platform's) and its whole review queue's capacity (``0`` = none). 908 #: Carried as stored, the way ``max_team_size`` is, so the edit form 909 #: prefills what the organiser set rather than a resolved number it 910 #: would then save back as an override. Not sensitive: both are 911 #: rules an author meets at the door anyway. 912 authoring_pending_per_team: int = 0 913 authoring_queue_capacity: int = 0 914 #: The contributor agreement an upload must accept — the version in 915 #: force and where to read it; ``""`` when the event asks for none. 916 #: Public, because the author reads both before uploading. 917 authoring_terms_version: str = "" 918 authoring_terms_url: str = "" 919 # Registration window + form config. The window is genuinely public 920 # information — an entrant needs to know when sign-ups close, and it 921 # differs from the play window — and the rest lets the admin edit form 922 # prefill without a second fetch. Nothing here is sensitive: the same 923 # field policy is already served to any signed-in user by 924 # ``GET /competitions/{id}/registration``. 925 registration_starts_at: datetime | None = None 926 registration_ends_at: datetime | None = None 927 max_team_size: int = 0 928 registration_review: bool = False 929 registration_fields: dict[str, RegistrationFieldPolicy] = Field(default_factory=dict) 930 # Public deliberately: how long entrants' personal data is kept is 931 # part of the notice they are owed, not an internal setting. 932 pii_retention_days: int = 0 933 # Which rule ranks this board, and its tuning. Empty name means the 934 # platform default (``flag_count``). Public because the players are 935 # entitled to know how they are being scored. 936 scoring_rule: ScoringRuleName | Literal[""] = "" 937 scoring_params: dict[str, Any] = Field(default_factory=dict) 938 # Silent exercise mode, orthogonal to ``scoring_rule`` on purpose. 939 # ``CompetitionState.silent`` is the derivation. 940 silent: bool = False 941 # Whether an entrant here may withhold their identity, and what 942 # withholding looks like. Public for the reason ``scoring_rule`` 943 # above it is: somebody deciding whether to enter is entitled to 944 # know, and the admin edit form prefills from this row. 945 # 946 # ⚠️ Defaults ``forbidden`` here too, unlike ``agents_allowed`` 947 # directly above. That one is a *door* and gates fail open; this is 948 # a *promise*, and a client reading an older server's silence as 949 # "anonymity is on offer" would show a control that stores nothing — 950 # the worst failure this feature has, since the entrant believes 951 # they answered. 952 anonymous_entry: AnonymityPolicy = "forbidden" 953 anonymous_entry_kind: AnonymityKind = "pseudonymous" 954 # Server-derived: whether the rule is now frozen. The admin form 955 # disables the control off this rather than re-deriving the 956 # draft/phase rule client-side and disagreeing with the server. 957 scoring_locked: bool = False 958 # Server-derived projection. ``"upcoming"`` / ``"running"`` / ``"past"`` 959 # — saves the frontend from re-implementing the same string-compare 960 # the backend already does. 961 phase: Literal["upcoming", "running", "past"] = "running" 962 # Declared play sessions, empty for a single-window competition. 963 # Public: an entrant has to know the event runs 08:00–16:00 daily, 964 # and the schedule is the first thing a competition page shows. 965 sessions: list[CompetitionSessionInfo] = Field(default_factory=list) 966 # Server-derived: whether play is open *right now*. Deliberately not 967 # folded into ``phase`` — a competition between its 16:00 and the 968 # next 08:00 is still running (board live, registration open, scoring 969 # window unmoved), and re-classifying it would change what every 970 # existing ``phase`` consumer says about a live event. 971 # 972 # ``True`` for every competition with no sessions, which is the whole 973 # existing corpus: they are gated by nothing, so play is never shut. 974 play_open: bool = True 975 # When play reopens. ``None`` means there is no next session — either 976 # none are declared, or the last one has ended. The closed-for-now 977 # card counts down to this, so it is the one field it needs. 978 next_session_starts_at: datetime | None = None 979 # When the current session closes. ``None`` outside a session, and 980 # also for a competition that declares none — there is no session to 981 # name, and reporting the window's end here would put a "session ends 982 # at" countdown on an event that never had sessions. 983 current_session_ends_at: datetime | None = None 984 # How long after the event the engagement report stays writable. 985 # Public for the same reason ``sessions`` is: a team writing one has 986 # to know when it locks. ``0`` means it closes with the event. 987 report_window_s: int = 0 988 # Server-derived: when the report window shuts. ``None`` for an 989 # open-ended competition — there is no instant to count from, and a 990 # fabricated deadline is worse than none. 991 # 992 # ⚠️ Deliberately *not* a ``report_open`` bool. That answer moves with 993 # the clock, so a cached page would assert a state that has since 994 # changed; an instant stays true and the client counts down from it. 995 report_closes_at: datetime | None = None 996 # Whether this competition runs a classic AWD tick loop. Server-derived 997 # from the ``awd_matches`` side table through ``core.awd.is_awd_match``, 998 # never re-derived per client: it decides which board a player is sent 999 # to (``/awd`` vs ``/scoreboard``) and whether a gamebox page exists at 1000 # all, so two clients answering it differently is two clients 1001 # disagreeing about how the event is played. 1002 # 1003 # A bool rather than a ``format`` enum on purpose. ``docs/awd-plan.md`` 1004 # §14.1 refuses a ``GameMode`` taxonomy at N=2 — a format is a point on 1005 # six orthogonal axes, not a subclass — and this field claims only the 1006 # fact the side table already stores. ``False`` is every competition 1007 # that predates AWD, which is the whole existing corpus. 1008 is_awd_match: bool = False 1009 # Whether this competition's standings live in the match rather than 1010 # in the scoring rule — ``is_awd_match and not challenge_ids``. 1011 # 1012 # ⚠️ **Not a rename of ``is_awd_match``, and the conjunction is the 1013 # whole field.** A competition may run a match *and* hang challenges 1014 # (``UserCompetition.is_awd_match``'s own comment anticipates the 1015 # mixed case), and that competition's Jeopardy board is real; keying 1016 # a board on the match alone would blank one with solves on it. 1017 # 1018 # ⚠️ It exists because the failure is **not** the empty board it 1019 # looks like. Measured on a pure rounds match with three settled 1020 # rounds: ``/scoreboard`` answers with every team at zero and ranks 1021 # complete, so the order falls to the tie break and puts the team 1022 # that is *losing* on top, while ``/awd/board`` carries the real one. 1023 # An empty board reads as broken and gets investigated; a full board 1024 # where everybody has zero reads as "the match just started" — and it 1025 # names the wrong leader on a projector. 1026 # ``ctfy/tests/test_pure_rounds_match_standings.py`` keeps that 1027 # measurement as a test rather than as this paragraph. 1028 # 1029 # ⚠️ Composed here rather than ``&&``-ed in the browser, for the 1030 # reason ``expects_report`` is: two clients composing it differently 1031 # is two clients disagreeing about where an event's standings are. 1032 # The wall and the leaderboard both need it, which is already two. 1033 ranks_by_match: bool = False 1034 #: The compact round clock, present only for an ``is_awd_match`` 1035 #: competition that has a scheduled match, ``None`` otherwise. Lets a 1036 #: list card and the detail header draw "round N / M · mm:ss left" 1037 #: without a per-row ``/awd/match`` fetch. See ``AwdClockBrief``. 1038 #: 1039 #: ⚠️ ``None`` on a match with no ``starts_at`` (unscheduled) as well 1040 #: as on a non-match — the card reads its absence as "no round bar", 1041 #: never as round zero. 1042 awd_clock: AwdClockBrief | None = None 1043 # Whether this board puts a value on individual challenges — 1044 # ``ctfy.server.scoring.prices_challenges`` of this competition's own 1045 # rule. 1046 # 1047 # ⚠️ **An axis, so the wall's scene registry can ask it.** The set of 1048 # scenes a wall rotates through is a property of the *format*, never 1049 # of the data: a panel that appeared the moment somebody solved 1050 # something would re-map every viewer's digit keys mid-event. 1051 # 1052 # ⚠️ **Composed here so no client names rules.** `scoring_rule in 1053 # {"static_points", "dynamic_points"}` is the shape that writes 1054 # itself in a component, and it drops the panel from an AWD+ board — 1055 # whose attack half prices exactly like a points board — and goes 1056 # stale the day a sixth rule is registered. 1057 prices_challenges: bool = False 1058 # Whether every team owes this competition a written engagement 1059 # report — ``core.competitions.expects_report``, the same verdict 1060 # ``CompetitionMembershipInfo.expects_report`` already carries for 1061 # the sidebar. 1062 # 1063 # ⚠️ **Denormalized rather than composed by the client**, even though 1064 # both halves of the disjunction (``silent`` and ``scoring_rule``) 1065 # are already on this model. That is exactly what makes a `||` in a 1066 # component tempting, and it would be the *third* place the rule is 1067 # spelled — after which two clients disagree about whether an event 1068 # has a deliverable. Same argument that put ``is_awd_match`` and 1069 # ``can_replay`` here rather than in a page. 1070 # 1071 # ⚠️ **Needed on the *public* model, not just the membership one**, 1072 # because the sidebar entry it already feeds is membership-gated — 1073 # correctly, since that block is the registered player's nav. So a 1074 # reader deciding whether to enter had no surface anywhere saying 1075 # this event is graded on a written report, which is the one thing 1076 # about the exercise format they most need before they sign up. 1077 # 1078 # Defaults ``False``: reading a missing field as true hangs a report 1079 # card on every Jeopardy competition, which is the claim the 1080 # player-side link waited to be able to make. 1081 expects_report: bool = False 1082 #: Why an invitation to this competition cannot be acted on yet, or 1083 #: ``""`` — the ``core.competitions.invitation_block`` verdict. 1084 #: 1085 #: ⚠️ **The organiser's half of a fact the invitee already gets.** 1086 #: An invitation grants ``access`` and says nothing about ``status``, 1087 #: so one written against a draft is stored, correct and inert. The 1088 #: invitee's inbox says so; the organiser's roster did not, and the 1089 #: roster is the surface where they form a belief about it — every 1090 #: invitee sits at *invited* for ever because nobody **can** register, 1091 #: which reads as a field that is not responding and invites the 1092 #: wrong action (chase them) over the right one (publish). 1093 #: 1094 #: ⚠️ **Composed server-side though ``status`` is on the wire beside 1095 #: it**, for ``expects_report``'s reason: two clients deriving it 1096 #: differently is two clients disagreeing about whether an organiser's 1097 #: invitations are going anywhere. And it is the *same producer* the 1098 #: invitee's card reads, so the two people waiting on each other 1099 #: cannot be told different things — ``AuthoredChallengeInfo.curation`` 1100 #: is the precedent, and like that one it is asserted by driving both 1101 #: routes in one test rather than by trusting the shared call. 1102 #: 1103 #: ⚠️ Always ``""`` for anybody who is not an admin here, and that is 1104 #: not a reason to move it to an admin model: a non-admin can only 1105 #: resolve a competition that *is* published, so the empty answer is 1106 #: the true one rather than a withheld one. 1107 invitations_blocked: InvitationBlock = "" 1108 # The competition this one is a *virtual sitting* of, empty for a 1109 # real one. 1110 # 1111 # On the wire because the marker is a rendering decision: the row's 1112 # ``title`` is the source's verbatim, deliberately, so a "(virtual)" 1113 # suffix baked into the database would be untranslatable English in 1114 # every locale's listing. A client reads this and labels it itself. 1115 virtual_of: str = "" 1116 # Whether this contest can be replayed on your own clock — the fact 1117 # a client renders its "Replay" button from, so it does not have to 1118 # POST and read a 400 to find out. 1119 # 1120 # Derived server-side from ``core.virtual.can_replay``, never per 1121 # client, for the same reason ``is_awd_match`` is: a client working 1122 # the refusal set out itself is a second implementation of 1123 # ``plan_virtual_run``, and it would drift once, silently, into a 1124 # button that fails the moment it is pressed. 1125 # 1126 # ⚠️ Deliberately **not** viewer-scoped, unlike ``can_participate``. 1127 # "Am I signed in" the client already knows, and "have I already 1128 # started one" is answered by the route being idempotent — asking it 1129 # here would put a per-user allowlist read inside a projection that 1130 # runs once per row of the listing, which is the N+1 shape three 1131 # other fields on this model have already had to have removed. 1132 # Defaults ``False``: a button that always 400s is worse than none. 1133 can_replay: bool = False 1134 # Total registered teams. Surfaced on cards so users can gauge 1135 # popularity at a glance. 1136 registered_count: int = 0 1137 # Viewer-scoped fields — see class docstring. 1138 is_registered: bool = False 1139 # Whether the viewer may register for this comp. Always ``True`` for 1140 # ``public`` comps; for private comps it's ``True`` only when the 1141 # viewer is invited or can administer it. Lets the frontend render a 1142 # "private — invitation required" locked card without a probe POST. 1143 # Defaults ``True`` so public / anonymous-on-public responses are open. 1144 can_participate: bool = True 1145 my_team_id: str = "" 1146 my_team_name: str = "" 1147 my_role: Literal["captain", "member", ""] = "" 1148 my_rank: int = -1 1149 my_score: int = -1 1150 my_solves: int = -1
Summary row used in the list view.
Per-viewer fields (is_registered / my_*) are populated only
when the request carries an authenticated user; for anonymous
requests they fall back to sentinels (empty strings, -1) so
the response shape stays stable. Front-end treats my_rank < 0
as "no value" rather than "rank zero".
1390class CompetitionInviteInfo(CtfyModel): 1391 """One participation-allowlist entry, for the admin "Invitations" 1392 card on a private competition. The body for POST is the shared 1393 ``GrantCompetitionAdminRequest`` (resolve by id or email). 1394 1395 ⚠️ **An invitation is a funnel, not a fact, and the card used to show 1396 only its first step.** The organiser's question — the one 1397 :class:`~ctfy.core.state.models.CompetitionEmailInviteState`'s own 1398 docstring says the claimed row is kept to answer — is "who did I 1399 invite that never turned up", and being invited, holding an account 1400 and actually registering a team are three different answers. The row 1401 carried the first: a name, a timestamp and a boolean, so somebody who 1402 signed in and then never registered was indistinguishable from 1403 somebody fully in. :attr:`status` is that axis, and the three 1404 ``*_at`` stamps below are the moment each stage was reached. 1405 """ 1406 1407 user_id: str 1408 display_name: str = "" 1409 email: str = "" 1410 invited_by: str = "" 1411 #: Resolved from :attr:`invited_by`, because the raw id names nobody. 1412 #: ``""`` when the inviter's account is gone, or when the row was 1413 #: written by the *claim* path, which has no admin to name. 1414 invited_by_name: str = "" 1415 invited_at: datetime | None = None 1416 #: How far this invitation has got. See 1417 #: :data:`~ctfy.core.enums.CompetitionInviteStatus` for why the 1418 #: default is the weakest of the three. 1419 status: CompetitionInviteStatus = "pending_signup" 1420 #: When the invitation found its person — the moment an address with 1421 #: no account was claimed by a *verified* sign-in. Absent for 1422 #: somebody invited by id, who already had an account: there is no 1423 #: such moment for them, and their own registration date is not it. 1424 signed_up_at: datetime | None = None 1425 #: The team they registered with for *this* competition, and when. 1426 #: Empty on any row that has not reached ``registered``. 1427 team_id: str = "" 1428 team_name: str = "" 1429 registered_at: datetime | None = None 1430 #: The organiser's free-text note from when they invited, or ``""``. 1431 #: Shown on the roster so "why is this person on the list" is 1432 #: answerable without leaving the card. 1433 reason: str = "" 1434 1435 @computed_field 1436 @property 1437 def pending_signup(self) -> bool: 1438 """True while the invitation is still waiting for its person: the 1439 address was invited before anyone signed up with it, so there is 1440 no account to name yet. :attr:`user_id` is empty in that state, 1441 which is why the card keys those rows on :attr:`email`. 1442 1443 ⚠️ **Computed from :attr:`status` rather than carried beside it.** 1444 It was a stored field first and the status axis subsumes it, so 1445 the two could disagree — and the client uses this one to pick 1446 which of the two revoke routes to call, keyed differently 1447 (address vs id). A row that said ``pending_signup: false`` while 1448 its status said otherwise would DELETE ``.../invites/`` with an 1449 empty id. A ``computed_field`` because this is a *wire* model, per 1450 the split ``LlmBudgetRow.exhausted`` records. 1451 """ 1452 return self.status == "pending_signup"
One participation-allowlist entry, for the admin "Invitations"
card on a private competition. The body for POST is the shared
GrantCompetitionAdminRequest (resolve by id or email).
⚠️ An invitation is a funnel, not a fact, and the card used to show
only its first step. The organiser's question — the one
~ctfy.core.state.models.CompetitionEmailInviteState's own
docstring says the claimed row is kept to answer — is "who did I
invite that never turned up", and being invited, holding an account
and actually registering a team are three different answers. The row
carried the first: a name, a timestamp and a boolean, so somebody who
signed in and then never registered was indistinguishable from
somebody fully in. status is that axis, and the three
*_at stamps below are the moment each stage was reached.
1435 @computed_field 1436 @property 1437 def pending_signup(self) -> bool: 1438 """True while the invitation is still waiting for its person: the 1439 address was invited before anyone signed up with it, so there is 1440 no account to name yet. :attr:`user_id` is empty in that state, 1441 which is why the card keys those rows on :attr:`email`. 1442 1443 ⚠️ **Computed from :attr:`status` rather than carried beside it.** 1444 It was a stored field first and the status axis subsumes it, so 1445 the two could disagree — and the client uses this one to pick 1446 which of the two revoke routes to call, keyed differently 1447 (address vs id). A row that said ``pending_signup: false`` while 1448 its status said otherwise would DELETE ``.../invites/`` with an 1449 empty id. A ``computed_field`` because this is a *wire* model, per 1450 the split ``LlmBudgetRow.exhausted`` records. 1451 """ 1452 return self.status == "pending_signup"
True while the invitation is still waiting for its person: the
address was invited before anyone signed up with it, so there is
no account to name yet. user_id is empty in that state,
which is why the card keys those rows on email.
⚠️ Computed from status rather than carried beside it.
It was a stored field first and the status axis subsumes it, so
the two could disagree — and the client uses this one to pick
which of the two revoke routes to call, keyed differently
(address vs id). A row that said pending_signup: false while
its status said otherwise would DELETE .../invites/ with an
empty id. A computed_field because this is a wire model, per
the split LlmBudgetRow.exhausted records.
109class CompetitionMembershipInfo(CtfyModel): 110 """One row on ``MeResponse.competition_teams``. 111 112 Lets the frontend hydrate a per-comp team picker / "My 113 competitions" hub from a single ``/me`` call without N+1 114 fetches. Captain status surfaces in the UI for buttons gated 115 on ``role == "captain"`` (mint invite / kick / rename). 116 117 ``competition_title`` / ``competition_phase`` are denormalized 118 from the competition the membership points at so the switcher 119 and the dashboard can label rows by the *competition* (the 120 thing the user is choosing between) rather than the team name — 121 every user is auto-joined to an identically named personal team 122 per comp, so team name alone makes the rows indistinguishable. 123 """ 124 125 competition_id: str 126 competition_title: str 127 competition_phase: Literal["upcoming", "running", "past"] 128 team_id: str 129 team_name: str 130 role: Literal["captain", "member"] 131 # Whether this competition runs a classic AWD tick loop — the same 132 # fact ``CompetitionInfo.is_awd_match`` carries, denormalized here 133 # for the one reader that cannot fetch it. ``Sidebar`` is mounted 134 # above the routes while ``CompetitionContext`` lives inside 135 # ``CompetitionLayout``, so the nav cannot see the competition the 136 # layout already loaded; it *can* see this, through ``AuthContext``, 137 # with no request of its own. Without it the per-competition block 138 # sends every AWD player to the Jeopardy board, which a match never 139 # populates. 140 is_awd_match: bool = False 141 # Whether this competition runs silent (§4b) — the same fact 142 # ``CompetitionInfo.silent`` carries, denormalized here for the same 143 # reader and the same reason ``is_awd_match`` is: ``Sidebar`` is 144 # mounted above the routes, so it cannot see the competition 145 # ``CompetitionLayout`` already loaded, and it *can* see this through 146 # ``AuthContext`` with no request of its own. 147 # 148 # ⚠️ Without it the per-competition nav offers a Scoreboard entry 149 # for the whole of a silent engagement, and every standing route 150 # behind it refuses — a link that cannot work, for the one format 151 # where "how am I doing" is the question the platform exists to 152 # decline. 153 silent: bool = False 154 # Whether this competition expects an engagement report — the 155 # ``core.competitions.expects_report`` verdict, denormalized here for 156 # the same reader and the same reason the two flags above are. 157 # 158 # ⚠️ **Not the same question as ``silent``**, and reusing that one is 159 # the tempting mistake in both directions: a *loud* exercise 160 # competition grades reports and would get no link, while the 161 # disjunction is what a nav entry is entitled to claim. It is derived 162 # server-side rather than ``||``-ed by each client for the reason 163 # ``is_awd_match`` is: two clients composing it differently is two 164 # clients disagreeing about whether this event has a deliverable. 165 expects_report: bool = False 166 # How many of this team's current write-ups this competition still 167 # owes a verdict on — the cross-competition half of the report 168 # signal, which no other surface could carry. 169 # 170 # ⚠️ **On the membership, not on ``/meta``'s per-caller block.** The 171 # rail draws one tile per competition and every one of its signals 172 # comes off this single payload with no per-tile fetch, so a number 173 # here reaches a player standing anywhere on the platform — including 174 # below ``lg``, where the status bar is ``display: none`` and a chip 175 # there would have reached nobody on a phone. Measured before 176 # choosing: the bar has 167px spare for a member in a competition 177 # (``frontend/e2e/measure-status-bar.spec.ts``), so a chip was 178 # affordable and still wrong. 179 # 180 # ⚠️ **Gated on the competition being *scored*, never on 181 # ``expects_report``.** That one is ``silent OR exercise``, so a 182 # silent ``flag_count`` event expects a report and grades it never — 183 # a badge keyed on it would sit on such a tile for the life of the 184 # event, counting a verdict nobody is coming to give. The same 185 # distinction ``ReportCta`` makes by rendering no chip for 186 # ``not_scored``. 187 # 188 # ⚠️ **A count, and deliberately not a status.** ``waiting`` and 189 # ``queued`` are one fact from here (*they are holding our work*) and 190 # the difference between them is a question the report index answers 191 # with a queue position, one click away. Compressing two states into 192 # one badge is what would make the badge disagree with the page it 193 # links to. 194 # 195 # Defaults ``0``: an older server saying nothing must not paint a 196 # badge on every tile, which is the direction a wrong value is 197 # noticed in least. 198 reports_awaiting_verdict: int = 0 199 # Whether this competition asks entrants to write challenges — the 200 # same fact ``CompetitionInfo.authoring_enabled`` carries, 201 # denormalized here for the same reader and the same reason the 202 # three flags above are: ``Sidebar`` is mounted above the routes and 203 # can only see this through ``AuthContext``. 204 # 205 # ⚠️ Defaults **False**, so an older server offering no opinion 206 # leaves the entry absent. That is the opposite direction from every 207 # *gate* on this model, and it is right for the same reason 208 # ``CompetitionInfo.authoring_enabled`` defaults False: a nav entry 209 # is an offer, and offering one for a Jeopardy event tells its 210 # players there is an authoring surface the competition does not 211 # have. 212 authoring_enabled: bool = False 213 # Whether this competition has a rules page worth a nav entry — 214 # ``bool(CompetitionState.handbook)``, denormalized here for the 215 # same reader and the same reason ``is_awd_match`` is: ``Sidebar`` 216 # is mounted above the routes and cannot see ``CompetitionLayout``'s 217 # already-fetched competition. 218 # 219 # ⚠️ **A boolean, never the text.** The handbook is capped at 20 000 220 # characters and ``/me`` carries one of these rows per competition 221 # the caller is in, so publishing the document here would put the 222 # whole set on every page load — ``AuthoredChallengeSummary``'s 223 # recorded defect through a second door. 224 # 225 # ⚠️ Defaults ``False`` for ``authoring_enabled``'s reason: an older 226 # server sends no field, and inventing a rules entry for every 227 # competition points a nav row at a page nobody has written. 228 has_handbook: bool = False 229 # Whether the competition carries any attachment (#1808) — the rules 230 # page's other content, so the nav entry shows for an event whose 231 # rules are only a file. Its own flag rather than folded into 232 # ``has_handbook``: a flag named for the wrong fact is shape 4. 233 # Defaults ``False`` for ``has_handbook``'s reason. 234 has_attachments: bool = False 235 # ⚠️ **The authoring *window* is deliberately not here.** The rail's 236 # only consumer is the nav entry, which must stay visible after the 237 # window closes — a verdict is read long after the last upload — so 238 # nothing would look at it, and `/me`'s producer would have to 239 # remember to fill two more fields per membership. Unset it is a 240 # claim ("this event has no window") on every competition that does. 241 # `CompetitionInfo` carries the pair, and the authoring page reads it 242 # off the competition it is already given. 243 # Rail-enrichment fields (denormalized so the Slack-style rail can 244 # render instance count / rank / a time-progress ring from one /me 245 # call, no per-tile fetch fan-out). 246 # 247 # Live instances for THIS team in THIS comp. Mirrors 248 # InstanceCountContext semantics (all non-terminal InstanceState 249 # rows for the team — terminal ones already left the live store as 250 # InstanceRecord); 0 when none. 251 running_instances: int = 0 252 # Competition window, copied straight off the CompetitionState the 253 # /me loop already fetches. "" start = live immediately, "" end = 254 # never ends (same convention as CompetitionState / phase()). 255 starts_at: datetime | None = None 256 ends_at: datetime | None = None 257 # The two facts a *pure rounds match* is read by — the same pair 258 # ``CompetitionInfo`` carries, from the same producers 259 # (``core.competitions.ranks_by_match`` and ``awd_clock_brief``). 260 # 261 # ⚠️ **The window above is not the event for such a competition.** The 262 # runbook's exhibition has no window of its own, so ``competition_ 263 # phase`` is ``running`` from creation on; the rail read that and put 264 # a running pie on a tile three hours before kickoff, while the page 265 # it links to said 即将开始. The browser narrows the window to the 266 # match's (``frontend/src/lib/headlineWindow.ts``) — which it can only 267 # do if the kickoff and the last round's close reach it. 268 # 269 # ⚠️ Default ``False`` / ``None``: an older server sends neither, and 270 # the tile then reads the competition's own window exactly as it 271 # always did — the reading that claims nothing new. 272 ranks_by_match: bool = False 273 awd_clock: AwdClockBrief | None = None 274 # Caller's current rank in this comp. -1 sentinel = "not computed" 275 # (upcoming phase, or the per-request rank budget was exhausted) — 276 # matches the existing my_rank<0 frontend convention. 277 my_rank: int = -1 278 # Registered-team count, the denominator the UI shows as "#3 / 40". 279 competition_team_count: int = 0
One row on MeResponse.competition_teams.
Lets the frontend hydrate a per-comp team picker / "My
competitions" hub from a single /me call without N+1
fetches. Captain status surfaces in the UI for buttons gated
on role == "captain" (mint invite / kick / rename).
competition_title / competition_phase are denormalized
from the competition the membership points at so the switcher
and the dashboard can label rows by the competition (the
thing the user is choosing between) rather than the team name —
every user is auto-joined to an identically named personal team
per comp, so team name alone makes the rows indistinguishable.
100class CompetitionRecords(CtfyModel): 101 """Everything a scoring script needs, in one file. 102 103 Lists are complete: an export is the whole event or it is not one. 104 """ 105 106 competition_id: str 107 title: str = "" 108 scoring_rule: str = "" 109 exported_at: datetime 110 challenge_ids: list[str] = Field(default_factory=list) 111 teams: list[RecordTeam] = Field(default_factory=list) 112 solves: list[RecordSolve] = Field(default_factory=list) 113 submissions: list[RecordSubmission] = Field(default_factory=list) 114 eval_runs: list[RecordEvalRun] = Field(default_factory=list) 115 authored_challenges: list[RecordAuthoredChallenge] = Field(default_factory=list)
Everything a scoring script needs, in one file.
Lists are complete: an export is the whole event or it is not one.
1319class CompetitionRegistrationInfo(CtfyModel): 1320 """One row in the admin "who's registered" table.""" 1321 1322 team_id: str 1323 team_name: str = "" 1324 registered_at: datetime | None = None
One row in the admin "who's registered" table.
161class CompetitionRoster(CtfyModel): 162 """``GET /competitions/{id}/roster`` — every registered team with its 163 members, in one read. 164 165 Exists because the alternative is a fan-out: a caller that wants to 166 know who is playing has to call ``GET /teams/{id}`` once per team, 167 which for a 400-team event is 400 round trips for a page that has to 168 stay live. The backend answers it in three bulk queries whatever the 169 team count. 170 171 ⚠️ **Public identity only.** ``TeamMemberInfo`` carries 172 ``display_name`` / ``avatar_url`` and nothing else, which is exactly 173 what ``GET /teams/{id}`` already publishes per team. The entrant 174 dossier — ``real_name`` / ``phone`` / ``mailing_address``, 个人敏感信息 175 sealed at the backend boundary — reaches the reviewer roster 176 (``RegistrationTeamRow``) and nowhere else, and must never be added 177 here: this response is public and cacheable, so a field added to it 178 is a field published to everyone forever. 179 """ 180 181 teams: list[CompetitionRosterTeam] = Field(default_factory=list) 182 generated_at: float = 0.0
GET /competitions/{id}/roster — every registered team with its
members, in one read.
Exists because the alternative is a fan-out: a caller that wants to
know who is playing has to call GET /teams/{id} once per team,
which for a 400-team event is 400 round trips for a page that has to
stay live. The backend answers it in three bulk queries whatever the
team count.
⚠️ Public identity only. TeamMemberInfo carries
display_name / avatar_url and nothing else, which is exactly
what GET /teams/{id} already publishes per team. The entrant
dossier — real_name / phone / mailing_address, 个人敏感信息
sealed at the backend boundary — reaches the reviewer roster
(RegistrationTeamRow) and nowhere else, and must never be added
here: this response is public and cacheable, so a field added to it
is a field published to everyone forever.
110class CompetitionRosterTeam(CtfyModel): 111 """One team plus its roster, for the competition-wide roster read.""" 112 113 team_id: str 114 name: str = "" 115 captain_user_id: str = "" 116 members: list[TeamMemberInfo] = Field(default_factory=list) 117 #: See :attr:`ctfy.server.models.ScoreboardEntry.anonymous`. 118 anonymous: bool = False 119 #: The squad's institution badge — see :attr:`TeamInfo.affiliation`, 120 #: which is the same fact about the same subject. 121 affiliation: AffiliationInfo | None = None
One team plus its roster, for the competition-wide roster read.
1359class CompetitionScoreDistribution(CtfyModel): 1360 """Aggregate companion to the (now server-paginated) competition 1361 scoreboard: the score-distribution histogram + the true team count. 1362 1363 The standings table pages server-side, so the histogram / "N teams" 1364 figure can't be re-derived from one page in the browser — this 1365 bins every registered team's ``flags_solved`` with the same 1366 adaptive scheme the frontend used to do client-side. 1367 """ 1368 1369 total_teams: int = 0 1370 bins: list[ScoreBucket] = Field(default_factory=list)
Aggregate companion to the (now server-paginated) competition scoreboard: the score-distribution histogram + the true team count.
The standings table pages server-side, so the histogram / "N teams"
figure can't be re-derived from one page in the browser — this
bins every registered team's flags_solved with the same
adaptive scheme the frontend used to do client-side.
1263class CompetitionScoreHistory(CtfyModel): 1264 """Top-N teams' score/rank progression, reconstructed from the 1265 competition's solve log (no snapshot dependency — exact for past 1266 and live competitions alike).""" 1267 1268 series: list[ScoreHistorySeries] = Field(default_factory=list) 1269 generated_at: float = 0.0
Top-N teams' score/rank progression, reconstructed from the competition's solve log (no snapshot dependency — exact for past and live competitions alike).
35class CompetitionSeriesConfig(CtfyModel): 36 """What an organiser sets on a series.""" 37 38 model_config = ConfigDict(extra="forbid") 39 40 name: str = "" 41 enabled: bool = False 42 43 weekday: int = 5 44 hour: int = 20 45 minute: int = 0 46 timezone: str = "UTC" 47 every_n_weeks: int = 1 48 first_at: AwareDatetime | None = None 49 lead_seconds: int = Field(default=172800, ge=0) 50 51 title_template: str = "" 52 description: str = "" 53 duration_seconds: int = Field(default=10800, ge=1) 54 pool: list[str] = Field(default_factory=list) 55 slate_size: int = Field(default=1, ge=1) 56 #: ⚠️ Typed like the two axes directly below it. It was the one 57 #: field on this model that was not, which is how a series could 58 #: spawn every occurrence under a rule the board cannot run. 59 scoring_rule: ScoringRuleName | Literal[""] = "" 60 scoring_params: dict[str, Any] = Field(default_factory=dict) 61 status: CompetitionStatus = "published" 62 access: CompetitionAccess = "public" 63 #: Seconds after each occurrence's ``ends_at`` that its report stays 64 #: open. ⚠️ Without it an ``exercise`` series was unofferable — every 65 #: occurrence took the platform default of ``0``, which means the 66 #: report closes with the event. 67 report_window_s: int = Field(default=0, ge=0) 68 69 @model_validator(mode="after") 70 def _cadence_is_expressible(self) -> CompetitionSeriesConfig: 71 """⚠️ Validated through `Cadence`, never re-checked here. 72 73 Two copies of "is this a legal weekday / a real timezone" is how 74 the form and the job come to disagree about which Saturdays 75 exist — and the job's copy is the one nobody sees fail. 76 """ 77 Cadence( 78 weekday=self.weekday, 79 hour=self.hour, 80 minute=self.minute, 81 timezone=self.timezone, 82 every_n_weeks=self.every_n_weeks, 83 ) 84 return self
What an organiser sets on a series.
87class CompetitionSeriesInfo(CompetitionSeriesConfig): 88 """A series as the organiser's page reads it.""" 89 90 id: str = "" 91 created_at: AwareDatetime | None = None 92 created_by: str = "" 93 #: The next scheduled start, or ``None`` for a series whose schedule 94 #: is over or unusable. Derived, never stored. 95 next_occurrence_at: AwareDatetime | None = None 96 #: How many occurrences already exist. A count is the right shape 97 #: *here* — this is a read for a human, not the job's idempotency, 98 #: which asks the database which instants exist. 99 occurrences_spawned: int = 0 100 #: ⚠️ Whether the job that actually spawns is switched on. It ships 101 #: enabled, unlike `awd_tick`, but a super-admin can pause it from 102 #: `/admin/scheduled-jobs` — and a series page that cannot say so 103 #: leaves an organiser reading a perfectly configured schedule that 104 #: will never run. Absent means not seeded, which is the same thing 105 #: as off to a reader; reporting it as on is the false reassurance 106 #: this field exists to remove. 107 spawn_job_enabled: bool = False 108 #: How many of `pool` declare a patchable surface. 109 #: 110 #: ⚠️ Derived here rather than in each client, which is the opposite 111 #: of how the competition wizard answers the same question — and the 112 #: difference is not an inconsistency. That form has just *browsed* 113 #: the catalog, so it holds the rows; a series client holds ids and 114 #: nothing else, and there is no by-id read on `GET /challenges`. Two 115 #: clients deriving it would be two clients disagreeing about whether 116 #: a weekly can score defence at all. 117 #: 118 #: The reason it is worth a field: `scoring_rule` and `pool` are set 119 #: in one form and nothing cross-checks them, so an "attack & 120 #: defence" weekly over a pool with nothing patchable scores zero 121 #: defence **every week**, unattended, with no error anywhere. This 122 #: is a report, never a refusal — a pool an organiser intends to fill 123 #: later is legitimate. 124 defensible_pool_count: int = 0 125 #: Pool entries the catalog does not have. A separate and equally 126 #: silent failure: `plan_slate` refuses an occurrence whose slate it 127 #: cannot fill, so a pool naming a since-deleted challenge produces a 128 #: Saturday with no contest rather than an error. 129 unknown_pool_ids: list[str] = Field(default_factory=list) 130 #: Pool entries **another series** also draws from — the third member 131 #: of the same family, and the one with a security edge rather than a 132 #: scoring one. 133 #: 134 #: `plan_slate` consumes a pool *within one series*, so two series 135 #: overlapping is invisible from either: nothing refuses, nothing 136 #: logs, and both weeklies are individually correct. What it costs is 137 #: that a challenge played on the internal ladder is **pre-leaked** 138 #: when the public one draws it, and the first sign is a public 139 #: Saturday where some squad solves in ninety seconds. 140 #: 141 #: A report, never a refusal, exactly like the two above: an 142 #: organiser staging both series out of one draft pool, or 143 #: deliberately re-running a problem for a different audience, is 144 #: doing something legitimate. 145 shared_pool_ids: list[str] = Field(default_factory=list)
A series as the organiser's page reads it.
302class CompetitionSessionInfo(CtfyModel): 303 """One playable slice of a competition's window, on the wire.""" 304 305 starts_at: datetime 306 ends_at: datetime
One playable slice of a competition's window, on the wire.
581class CompetitionUpdate(CtfyModel): 582 """PATCH body — every field optional, ``None`` means "leave alone".""" 583 584 title: str | None = Field(default=None, min_length=1, max_length=200) 585 description: LocalizedText | None = None 586 #: ``None`` leaves the handbook alone; ``{}`` clears it. The route 587 #: stamps ``handbook_updated_at`` only when the text really differs, 588 #: so re-saving the form does not move that date. 589 #: 590 #: ⚠️ **The map is replaced, never merged.** Every other field on 591 #: this endpoint replaces, and a merge would make clearing one 592 #: locale impossible — a client that sends only ``zh-CN`` means 593 #: "these are the translations now". The CLI reads the stored map 594 #: and sends the merged result, which is where a per-locale flag 595 #: belongs. 596 handbook: LocalizedText | None = None 597 598 _v_description = field_validator("description", mode="before")( 599 lambda v: localized_field(v, cap=MAX_DESCRIPTION_CHARS, field="description") 600 ) 601 _v_handbook = field_validator("handbook", mode="before")( 602 lambda v: localized_field(v, cap=MAX_HANDBOOK_CHARS, field="handbook") 603 ) 604 starts_at: datetime | None = None 605 ends_at: datetime | None = None 606 challenge_ids: list[str] | None = Field(default=None) # no cap — see CompetitionCreate 607 status: CompetitionStatus | None = None 608 access: CompetitionAccess | None = None 609 eval_standard: bool | None = None 610 corpus_sha: str | None = None 611 # ⚠️ ``None`` means untouched, like every field here. A handler 612 # reading truthiness instead could only ever turn this *on*, so an 613 # organiser's opt-out would be undone by the next unrelated save. 614 agents_allowed: bool | None = None 615 # ⚠️ ``None`` means untouched, for the reason directly above. 616 authoring_enabled: bool | None = None 617 # ⚠️ ``None`` means untouched, for the reason directly above: the 618 # edit form re-sends every field, so a plain ``False`` default would 619 # turn every unrelated save into a wall panel being switched off. 620 agent_pulse: bool | None = None 621 # ⚠️ ``None`` means untouched, for the reason directly above: the 622 # edit form re-sends every field, so a plain ``False`` default would 623 # re-open human registration on every unrelated save of an 624 # exhibition. See CompetitionState.entrants_are_agents. 625 entrants_are_agents: bool | None = None 626 # The authoring window, distinct from both the play window and the 627 # registration one; unset on both ends means "for as long as the 628 # event runs". `core.competitions.authoring_phase` is the rule. 629 authoring_starts_at: datetime | None = None 630 authoring_ends_at: datetime | None = None 631 # ⚠️ ``None`` means untouched, like every field here, and ``0`` is a 632 # real value on both: the way back to the platform's per-team cap, 633 # and to no capacity. See CompetitionState.authoring_pending_per_team 634 # / authoring_queue_capacity. 635 authoring_pending_per_team: int | None = Field(default=None, ge=0) 636 authoring_queue_capacity: int | None = Field(default=None, ge=0) 637 # ``""`` is a real value on both — how an organiser stops asking for 638 # an agreement — and the pair is checked against the state the patch 639 # *leaves*, so clearing one alone is refused. 640 authoring_terms_version: str | None = Field(default=None, max_length=64) 641 authoring_terms_url: WebUrl | None = Field(default=None, max_length=500) 642 # Rejected with 409 once the competition is live — re-submitting the 643 # value it already holds is a no-op, so the edit form can keep 644 # sending every field when the admin saves any of them. 645 # 646 # ⚠️ Typed like ``CompetitionCreate``'s, and the reason is that they 647 # are two doors onto one field. Narrowing only create left an unknown 648 # rule refused by pydantic there and by ``_check_scoring``'s 649 # ``build_rule`` here — same 422, two different bodies, for the same 650 # mistake. Three states, all real: ``None`` leaves it alone, ``""`` 651 # selects the platform default, a member names a rule. 652 scoring_rule: ScoringRuleName | Literal[""] | None = None 653 scoring_params: dict[str, Any] | None = None 654 # ⚠️ Optional like every field here: ``None`` means untouched. 655 # Flipping silence mid-event is a real organiser action (a range run 656 # loud for a practice round, then silent for the graded one), so it 657 # is deliberately *not* frozen the way ``scoring_rule`` is — silence 658 # changes what the player is told from here on, it does not re-rank a 659 # board they have already seen. 660 silent: bool | None = None 661 #: ``None`` leaves the schedule alone; ``[]`` clears every session and 662 #: returns the competition to a single continuous window. 663 sessions: list[CompetitionSessionInfo] | None = None 664 # ⚠️ Optional like every field here: ``None`` means untouched. Not 665 # frozen the way ``scoring_rule`` is — extending the report period on 666 # the day is ordinary event work, and it re-ranks nothing. 667 report_window_s: int | None = None 668 # ⚠️ ``None`` means untouched, like every field here. **Widening is 669 # free; narrowing is refused once anybody has relied on the old 670 # setting** (``anonymity_promised``, 409) — an organiser flipping 671 # ``optional`` back to ``forbidden`` would publish every entrant who 672 # asked to be withheld, in one click, permanently, with nothing on 673 # the page to say so. The *kind* is free in both directions: it 674 # decides whether a withheld row is renamed or removed, and neither 675 # answer publishes a name. 676 anonymous_entry: AnonymityPolicy | None = None 677 anonymous_entry_kind: AnonymityKind | None = None 678 # --- registration --------------------------------------------------- 679 # The window sign-ups are open, distinct from the play window above. 680 # ``CompetitionState.registration_starts_at`` is the derivation. 681 registration_starts_at: datetime | None = None 682 registration_ends_at: datetime | None = None 683 # Per-competition roster cap. 0 defers to the platform-wide setting, 684 # so two events on one deployment can carry different rules. 685 max_team_size: int | None = Field(default=None, ge=0) 686 # When true a newly registered team lands ``pending`` and cannot play 687 # until an organiser approves it. 688 registration_review: bool | None = None 689 # field name -> policy; anything absent is treated as hidden. 690 # ``CompetitionState.registration_fields`` is the derivation. ⚠️ The 691 # ``None`` here is this model's partial-update sentinel — *leave the 692 # map alone* — and is not a third policy value. 693 registration_fields: dict[str, RegistrationFieldPolicy] | None = None 694 #: ``None`` leaves it alone, same as every other field here. 695 pii_retention_days: int | None = Field(default=None, ge=0) 696 697 _norm_times = field_validator( 698 "starts_at", 699 "ends_at", 700 "registration_starts_at", 701 "registration_ends_at", 702 # ⚠️ The authoring window travels the same road: an admin form 703 # sends "" for a cleared date, and without this it is a 422 704 # rather than "leave it unset". 705 "authoring_starts_at", 706 "authoring_ends_at", 707 mode="before", 708 )(_blank_to_none) 709 710 @field_validator("registration_fields") 711 @classmethod 712 def _known_registration_fields( 713 cls, v: dict[str, RegistrationFieldPolicy] | None 714 ) -> dict[str, RegistrationFieldPolicy] | None: 715 return None if v is None else _check_registration_fields(v)
PATCH body — every field optional, None means "leave alone".
995class ContributorRow(CtfyModel): 996 """One author named on at least one published upload (plan P2.5). 997 998 ⚠️ **No points for uploading, so no ranking either.** The numbers are 999 what players did with the challenges — the reception — and the list 1000 is ordered by name: a roll of who wrote the catalog, never a board 1001 the review queue would then be gamed for. 1002 """ 1003 1004 user_id: str 1005 author_display_name: str 1006 #: Published uploads naming this person that this viewer may read. 1007 challenges: int 1008 #: Finishes summed over those challenges. ⚠️ ``None`` when any one of 1009 #: them has its tallies withheld: a partial sum is a claim about the 1010 #: whole, the absent-split-is-not-a-zero-split trap. 1011 solves: int | None = None 1012 #: Each reaction summed over the same challenges, largest first — 1013 #: per kind and never one total, since a report of a defect is not 1014 #: praise. ``None`` when withheld, for either of the row's reasons. 1015 reactions: list[ReactionCount] | None = None
One author named on at least one published upload (plan P2.5).
⚠️ No points for uploading, so no ranking either. The numbers are what players did with the challenges — the reception — and the list is ordered by name: a roll of who wrote the catalog, never a board the review queue would then be gamed for.
367class CreateFineGrainedTokenRequest(CtfyModel): 368 """Body for ``POST /auth/tokens`` (mints a fine-grained token). 369 370 Back-compat: when ``competition_access`` / ``permissions`` are 371 omitted the server mints the broad legacy profile (every 372 competition, full participate access) so existing 373 ``mint_token(label)`` callers — SDK, CLI, MCP — keep getting a 374 token equivalent to the old agent token. 375 """ 376 377 label: str = Field(default="", max_length=64) 378 # Lifetime in days. ``None`` uses the server default (30); ``0`` means 379 # "never expires". The server caps this at ~5 years to reject absurd 380 # values. 381 expires_in_days: int | None = Field(default=None, ge=0, le=365 * 5) 382 #: Start from a named template (``GET /auth/tokens/templates``). 383 #: 384 #: ⚠️ **A starting point the explicit fields override, never a 385 #: fourth way to say the same thing.** ``competition_access`` / 386 #: ``permissions`` / ``expires_in_days`` win where the caller sent 387 #: them, so a client can take ``agent_play`` and narrow it to two 388 #: competitions in one request — which is the flow the wizard walks 389 #: and would otherwise need two. 390 #: 391 #: ⚠️ Typed, unlike ``permissions`` one field down, and for the 392 #: opposite reason: there is no clamp for a template name. An 393 #: unrecognised one has no safe reading — falling back to the broad 394 #: default would mint a *wider* token than was asked for — so the 395 #: refusal is the feature and pydantic is the cheapest place to 396 #: state it. 397 template: TokenTemplateName | None = None 398 # ``None`` → legacy-broad default. Otherwise: none | all | selected. 399 competition_access: str | None = None 400 competition_ids: list[str] = Field(default_factory=list) 401 # Category → a ``PermissionLevel``. ⚠️ **Typed ``str`` on purpose**, 402 # unlike every other axis in this file: this is a *request*, and 403 # ``clamp_permissions`` exists to lower whatever arrives rather than 404 # refuse it. Narrowing the annotation would turn a clamp into a 422 405 # and break the tolerance the clamp was written for. ``None`` → 406 # legacy-broad default. 407 permissions: dict[str, str] | None = None
Body for POST /auth/tokens (mints a fine-grained token).
Back-compat: when competition_access / permissions are
omitted the server mints the broad legacy profile (every
competition, full participate access) so existing
mint_token(label) callers — SDK, CLI, MCP — keep getting a
token equivalent to the old agent token.
410class CreateFineGrainedTokenResponse(TokenInfo): 411 """Includes the plaintext ``token`` — returned once, never persisted.""" 412 413 token: str
Includes the plaintext token — returned once, never persisted.
489class CreateInviteRequest(CtfyModel): 490 """Body for ``POST /api/v1/nodes/invites``. 491 492 ``ttl_seconds`` controls how long the minted registration token stays 493 valid — it cannot be renewed, only re-created. 494 """ 495 496 ttl_seconds: int = Field(default=3600, ge=60, le=7 * 24 * 3600) 497 # Optional vendor binding: a node registered with this invite is stamped 498 # as that vendor's bring-your-own-node (docs/model-eval-vendor-tenancy.md 499 # §6.3). "" = a platform-shared node (the default). Validated to an 500 # existing vendor at mint time. 501 owner_vendor_id: str = ""
Body for POST /api/v1/nodes/invites.
ttl_seconds controls how long the minted registration token stays
valid — it cannot be renewed, only re-created.
504class CreateInviteResponse(CtfyModel): 505 """Plaintext registration token is returned **exactly once** here. 506 507 Subsequent ``GET /nodes/invites`` lookups only expose metadata; the 508 plaintext is never persisted — only ``token_hash`` is stored. 509 """ 510 511 id: str 512 registration_token: str 513 expires_at: datetime | None = None
Plaintext registration token is returned exactly once here.
Subsequent GET /nodes/invites lookups only expose metadata; the
plaintext is never persisted — only token_hash is stored.
964class CreditedChallengeRow(CtfyModel): 965 """One published upload whose author asked to be named (plan P2.5). 966 967 Only an upload in the catalog, credited ``display_name``, and not 968 withheld from this viewer by the anonymity of the event it came from 969 (`ctfy.server.anonymity.anonymise_author_credits`) is a row at all. 970 """ 971 972 id: str 973 name: str 974 category: ChallengeCategory = ChallengeCategory.WEB 975 difficulty: str = "" 976 author_user_id: str 977 author_display_name: str 978 #: Entrants (a team in one competition) that finished every question, 979 #: across every event the challenge is played in — never under one 980 #: scoring rule. ⚠️ ``None`` while a live silent exercise curating it 981 #: withholds its tallies, **emptied, never zeroed**: a ``0`` there 982 #: would be a claim that nobody has finished it. 983 solves: int | None = None 984 #: When the first entrant finished it; ``None`` when nobody has, or 985 #: when the tallies are withheld (then ``solves`` is ``None`` too). 986 first_solved_at: AwareDatetime | None = None 987 #: What players said, one entry per reaction given, largest first. 988 #: ``None`` when withheld: for ``solves``' reason, or because the 989 #: viewer may not read reaction counts at all — those are a signed-in 990 #: read (``submissions:read``, `routes.feedback.feedback_stats`), and a 991 #: page a guest can open must not be the door around that. 992 reactions: list[ReactionCount] | None = None
One published upload whose author asked to be named (plan P2.5).
Only an upload in the catalog, credited display_name, and not
withheld from this viewer by the anonymity of the event it came from
(ctfy.server.anonymity.anonymise_author_credits) is a row at all.
1153class DeceptionStats(CtfyModel): 1154 """Three states, of which only two are counted. 1155 1156 ``contacted`` and ``complied`` are each a stored count of crossings; 1157 ``resisted`` is the difference. There is no third counter because 1158 there is no moment at which a refusal happens for something to 1159 count — resistance is the absence of compliance after contact, and 1160 an absence cannot be recorded at the time it fails to occur. 1161 """ 1162 1163 contacted: int = 0 1164 complied: int = 0 1165 resisted: int = 0 1166 #: ``complied / contacted``. Absent rather than zero when nothing has 1167 #: been contacted: 0/0 is "no data", and rendering it as a 0% rate 1168 #: would assert that no agent was fooled, which is a claim the 1169 #: platform has not earned. 1170 comply_rate: float | None = None 1171 #: ⚠️ Rides on the wire so a reader cannot get the number without it. 1172 caveat: str = ( 1173 "resisted is an upper bound: the probe runs inside a container the " 1174 "player can compromise, so a missing compliance record is not " 1175 "evidence of restraint." 1176 )
Three states, of which only two are counted.
contacted and complied are each a stored count of crossings;
resisted is the difference. There is no third counter because
there is no moment at which a refusal happens for something to
count — resistance is the absence of compliance after contact, and
an absence cannot be recorded at the time it fails to occur.
378class DefenceInfo(CtfyModel): 379 """What a defender may do on this challenge — AWD+ only. 380 381 Present iff the challenge declares a ``patch:`` block. ``None`` 382 everywhere else, which is what lets a client decide whether to render 383 a defence surface at all: without this the only way to find out was 384 to POST a submission and read the refusal, so the web UI had no way 385 to know AWD+ existed. 386 387 ``targets`` is deliberately public. It is a whitelist, not a secret — 388 a defender is *given* this source (they can read it in their own 389 box), the refusal message already names a rejected path, and a UI 390 that cannot say which files it will accept sends players guessing. 391 """ 392 393 #: Challenge-relative paths a submission may replace. Anything else 394 #: is refused at submit time rather than at judge time. 395 targets: list[str] = Field(default_factory=list) 396 #: Whether ``patch.live`` declares a defender's shell, i.e. whether 397 #: ``ctfy patch shell`` / the terminal button will work here. False 398 #: means uploads are the only channel — a real and supported shape, 399 #: not a degraded one, so the UI says so rather than hiding. 400 live_shell: bool = False 401 #: The compose service the defender's shell opens in 402 #: (``patch.live.service``); ``""`` without a shell. 403 shell_service: str = "" 404 #: Whether that service is one the round loop **scores** — attacks and 405 #: probes. ⚠️ **False is a different box, not a smaller one**: a shell 406 #: in a sidecar that merely shares the source cannot restart, signal 407 #: or even reach the scored process, and a brief that said otherwise 408 #: sent agents hunting for a process that was not there. 409 shell_is_scored: bool = False 410 #: The command, run in the shell, that makes an edit take effect 411 #: (``patch.live.restart``). ``""`` = the challenge does not say — 412 #: *unknown*, never "edits reload themselves". 413 restart_command: str = "" 414 #: Where each patch target lives in the shell, and where its shipped 415 #: copy is. Only targets the live channel can reach are listed. 416 live_files: list[DefenceLiveFile] = Field(default_factory=list)
What a defender may do on this challenge — AWD+ only.
Present iff the challenge declares a patch: block. None
everywhere else, which is what lets a client decide whether to render
a defence surface at all: without this the only way to find out was
to POST a submission and read the refusal, so the web UI had no way
to know AWD+ existed.
targets is deliberately public. It is a whitelist, not a secret —
a defender is given this source (they can read it in their own
box), the refusal message already names a rejected path, and a UI
that cannot say which files it will accept sends players guessing.
366class DefenceLiveFile(CtfyModel): 367 """One patch target as the defender's shell sees it.""" 368 369 #: Challenge-relative path, as in ``DefenceInfo.targets``. 370 target: str 371 #: Where it lives inside ``DefenceInfo.shell_service`` — the file to edit. 372 path: str 373 #: Where the shipped copy lives in the same service, to restore a 374 #: broken edit by hand; ``""`` when the challenge does not say. 375 pristine_path: str = ""
One patch target as the defender's shell sees it.
1487class DefenceMatrixResponse(CtfyModel): 1488 """Who has patched what — the defence-side twin of the solve heatmap. 1489 1490 ⚠️ **``challenge_ids`` is configuration, never data.** It is the 1491 competition's defensible challenges, so a challenge nobody has 1492 patched still gets a column — which is information, and which keeps 1493 the columns from re-ordering mid-event as submissions land. The same 1494 reasoning the wall's scene registry states for `hasChallenges`. 1495 """ 1496 1497 challenge_ids: list[str] = Field(default_factory=list) 1498 rows: list[DefenceMatrixRow] = Field(default_factory=list)
Who has patched what — the defence-side twin of the solve heatmap.
⚠️ challenge_ids is configuration, never data. It is the
competition's defensible challenges, so a challenge nobody has
patched still gets a column — which is information, and which keeps
the columns from re-ordering mid-event as submissions land. The same
reasoning the wall's scene registry states for hasChallenges.
1467class DefenceMatrixRow(CtfyModel): 1468 """One team's line of the defence matrix.""" 1469 1470 team_id: str = "" 1471 team_name: str = "" 1472 #: ⚠️ Present because a wire model that carries a team identity needs 1473 #: a **stated fate**, not a reader. `AwdBoardEntry` and 1474 #: `VirtualGhostRow` shipped without one and both boards rendered the 1475 #: raw tag; `test_anonymity_readers.py` pins the producer set for 1476 #: exactly that reason. 1477 anonymous: bool = False 1478 #: ``{challenge_id: verdict}``. 1479 #: 1480 #: ⚠️ **A pair with no cell is absent, never a present ``""``.** That 1481 #: is `fold_defence_cells`' decision carried onto the wire unchanged: 1482 #: "not judged yet" and "never submitted" are one empty cell to a 1483 #: reader, so a third state would be one nothing draws differently. 1484 cells: dict[str, Verdict] = Field(default_factory=dict)
One team's line of the defence matrix.
336class DefenceSource(CtfyModel): 337 """The patchable source of an AWD+ challenge, as the defender gets it. 338 339 AWD+ hands every team the same vulnerable service **and its source** 340 (§15.1) — you cannot patch what you cannot read. Until this there 341 was no way to get it: the only channel was `patch.live`, i.e. ssh 342 into your own box, which is the *classic AWD* shape and leaves 343 virtual participation and upsolve impossible, since after the event 344 there is no box to enter. That is the feature §15.5 calls decisive 345 for a weekly series. 346 347 ⚠️ **Exactly `patch.targets`, never the tree.** A benchmark 348 directory also holds `solution/exploit.py` and the checker, and for 349 some challenges the groundtruth — serving the directory would hand a 350 player the reference exploit and the thing that grades them. The 351 whitelist is already the format's one real security boundary (see 352 `routes/patches.py`), so the download reuses it rather than 353 inventing a second, weaker one. 354 355 ``files`` is keyed by challenge-relative path with **base64** 356 values, which is deliberately the shape `POST /patches` accepts: a 357 defender — or an agent — round-trips download → edit → upload with 358 no transformation, and a binary target does not need a second code 359 path. 360 """ 361 362 challenge_id: str 363 files: dict[str, str] = Field(default_factory=dict)
The patchable source of an AWD+ challenge, as the defender gets it.
AWD+ hands every team the same vulnerable service and its source
(§15.1) — you cannot patch what you cannot read. Until this there
was no way to get it: the only channel was patch.live, i.e. ssh
into your own box, which is the classic AWD shape and leaves
virtual participation and upsolve impossible, since after the event
there is no box to enter. That is the feature §15.5 calls decisive
for a weekly series.
⚠️ Exactly patch.targets, never the tree. A benchmark
directory also holds solution/exploit.py and the checker, and for
some challenges the groundtruth — serving the directory would hand a
player the reference exploit and the thing that grades them. The
whitelist is already the format's one real security boundary (see
routes/patches.py), so the download reuses it rather than
inventing a second, weaker one.
files is keyed by challenge-relative path with base64
values, which is deliberately the shape POST /patches accepts: a
defender — or an agent — round-trips download → edit → upload with
no transformation, and a binary target does not need a second code
path.
383class DeleteMeRequest(CtfyModel): 384 """Body for ``DELETE /me``. ``confirm_display_name`` must match the 385 caller's user display name exactly (case-sensitive) — the typo 386 gate that stops a fat-fingered click from cascading the account's 387 data away. (Falls back to email when display_name is empty.) 388 389 ⚠️ **The typo gate is not the authorization half and never was.** A 390 display name is public — it is on the caller's own profile page and 391 on ``/me`` — so a stolen session reads it in the same request. The 392 second factor is ``current_password`` or ``code``, whichever 393 :func:`~ctfy.server.account_deletion.required_proof` says this 394 account can give; ``MeResponse.deletion_proof`` publishes which, so 395 a client renders one control rather than guessing. 396 397 ⚠️ **Both are optional here rather than one being required**, because 398 which one applies is a property of the account, not of the request — 399 and an account whose answer is ``none`` sends neither, which is 400 every account before this field existed. 401 """ 402 403 confirm_display_name: str = Field(min_length=1, max_length=120) 404 #: Re-entered password, for an account holding a password identity. 405 current_password: str = Field(default="", max_length=256) 406 #: The six digits mailed by ``POST /me/deletion-code``, for an 407 #: account with no password and a proved address. 408 code: str = Field(default="", max_length=32)
Body for DELETE /me. confirm_display_name must match the
caller's user display name exactly (case-sensitive) — the typo
gate that stops a fat-fingered click from cascading the account's
data away. (Falls back to email when display_name is empty.)
⚠️ The typo gate is not the authorization half and never was. A
display name is public — it is on the caller's own profile page and
on /me — so a stolen session reads it in the same request. The
second factor is current_password or code, whichever
~ctfy.server.account_deletion.required_proof() says this
account can give; MeResponse.deletion_proof publishes which, so
a client renders one control rather than guessing.
⚠️ Both are optional here rather than one being required, because
which one applies is a property of the account, not of the request —
and an account whose answer is none sends neither, which is
every account before this field existed.
344class DeploymentConfig(CtfyModel): 345 """Read-only deployment posture for the admin overview — env-only 346 knobs that are *not* on public ``/meta`` and not runtime-tunable via 347 platform-settings, so an operator can see how this deployment is 348 configured at a glance. ``rate_limiting_enabled`` is deliberately 349 absent: it is runtime-tunable, so the platform-settings page owns its 350 effective value + source tag. Defaults mirror ``CtfyConfig``.""" 351 352 single_process_guard: bool = True 353 auto_migrate: bool = True 354 password_auth_enabled: bool = True 355 # HSTS is emitted only when the deployment is reachable over https 356 # (``oauth_redirect_base`` is https) — see security_headers. 357 hsts_enabled: bool = False
Read-only deployment posture for the admin overview — env-only
knobs that are not on public /meta and not runtime-tunable via
platform-settings, so an operator can see how this deployment is
configured at a glance. rate_limiting_enabled is deliberately
absent: it is runtime-tunable, so the platform-settings page owns its
effective value + source tag. Defaults mirror CtfyConfig.
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.
248class DeviceCodeResponse(CtfyModel): 249 """RFC 8628-style response to ``POST /auth/device/code`` — the CLI 250 shows ``user_code`` + ``verification_uri`` and polls with 251 ``device_code``.""" 252 253 device_code: str 254 user_code: str 255 verification_uri: str 256 verification_uri_complete: str 257 expires_in: int 258 interval: int
RFC 8628-style response to POST /auth/device/code — the CLI
shows user_code + verification_uri and polls with
device_code.
278class DeviceInfoResponse(CtfyModel): 279 """What the approval page shows about a pending device request.""" 280 281 status: str 282 requested_at: datetime | None = None 283 requester_ip: str = "" 284 requester_user_agent: str = ""
What the approval page shows about a pending device request.
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.
265class DeviceTokenResponse(CtfyModel): 266 """Poll result for ``POST /auth/device/token``. ``status`` is one of 267 ``pending`` / ``approved`` / ``denied`` / ``expired``; ``token`` is 268 set only when ``approved``.""" 269 270 status: str 271 token: str = ""
465class DifficultyStat(CtfyModel): 466 difficulty: Difficulty | Literal[""] = "" 467 solved: int = 0 468 total: int = 0
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
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.
54class EmailCodeAuthInfo(CtfyModel): 55 """Whether passwordless email sign-in is offered. 56 57 Tracks outbound-email configuration exactly — there is no separate 58 feature flag, because a sign-in method that cannot deliver its code 59 is not a sign-in method, and offering the button anyway just produces 60 a dead end. 61 """ 62 63 enabled: bool = False
Whether passwordless email sign-in is offered.
Tracks outbound-email configuration exactly — there is no separate feature flag, because a sign-in method that cannot deliver its code is not a sign-in method, and offering the button anyway just produces a dead end.
104class EmailSuppressionCreate(CtfyModel): 105 email: str 106 #: Free text; stored as the ``detail`` an operator reads later when 107 #: deciding whether the entry still applies. 108 detail: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
78class EmailSuppressionInfo(CtfyModel): 79 """One address the platform will not send to.""" 80 81 email: str 82 #: ``hard_bounce`` | ``complaint`` | ``manual``. 83 reason: str 84 #: The provider's own words, when it gave any. 85 detail: str = "" 86 #: ``webhook`` | ``admin`` — a provider's verdict or a human's. 87 source: str = "" 88 #: The message whose bounce caused this, for cross-referencing the 89 #: outbox row that sent it. 90 provider_message_id: str = "" 91 created_at: str = "" 92 created_by: str = ""
One address the platform will not send to.
95class EmailSuppressionListResponse(CtfyModel): 96 items: list[EmailSuppressionInfo] 97 total: int 98 #: False when no Svix secret is set, in which case nothing will ever 99 #: be added automatically and the list is manual-only. The UI says so 100 #: rather than letting an operator conclude that nothing bounces. 101 webhook_configured: bool
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
55class EmailTemplateInfo(CtfyModel): 56 """One row of the admin template list. 57 58 ``category`` and ``forced`` come from the same registry the 59 preference matrix reads, so the admin sees *why* a message is or is 60 not suppressible without cross-referencing another page. 61 """ 62 63 name: str 64 category: str 65 forced: bool 66 subject: str
69class EmailTemplateListResponse(CtfyModel): 70 templates: list[EmailTemplateInfo] 71 #: False when Resend is unconfigured. The UI disables Send rather 72 #: than letting the operator discover it through a 400. 73 email_configured: bool 74 #: Where a test send goes when the request omits a recipient. 75 default_recipient: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
140class EmailVerificationSent(CtfyModel): 141 """Answer to ``POST /auth/email-verification`` and ``POST /me/emails``. 142 143 ``handover`` says what redeeming the code that was just sent would 144 do to *another* account, so the person can be told before they go to 145 their inbox rather than after. 146 147 ⚠️ **The default is ``"none"`` and that is safe because the gate is 148 elsewhere.** A producer that forgets, or an older server a newer 149 client is talking to, reports an ordinary claim — and the only 150 destructive outcome, orphaning the other account, is refused at the 151 *exchange* unless that request carries an explicit acknowledgement. 152 So silence here costs a missing warning, never a silent taking; the 153 reverse default would be a new claim about somebody else's account. 154 """ 155 156 sent: bool = True 157 expires_in_minutes: int = 0 158 handover: HandoverKind = "none"
Answer to POST /auth/email-verification and POST /me/emails.
handover says what redeeming the code that was just sent would
do to another account, so the person can be told before they go to
their inbox rather than after.
⚠️ The default is "none" and that is safe because the gate is
elsewhere. A producer that forgets, or an older server a newer
client is talking to, reports an ordinary claim — and the only
destructive outcome, orphaning the other account, is refused at the
exchange unless that request carries an explicit acknowledgement.
So silence here costs a missing warning, never a silent taking; the
reverse default would be a new claim about somebody else's account.
161class EmailVerificationStatus(CtfyModel): 162 """Where an account stands on proving its address. 163 164 ``verified`` is a ``computed_field`` rather than a stored bool 165 because this is a wire model and the two must never be able to 166 disagree — one fact, published in the shape a client wants to read. 167 """ 168 169 email: str = "" 170 verified_at: AwareDatetime | None = None 171 172 @computed_field 173 @property 174 def verified(self) -> bool: 175 return self.verified_at is not None
Where an account stands on proving its address.
verified is a computed_field rather than a stored bool
because this is a wire model and the two must never be able to
disagree — one fact, published in the shape a client wants to read.
386class ErrorResponse(CtfyModel): 387 """Unified shape for every HTTP 4xx/5xx body produced by platform routes. 388 389 ``detail`` mirrors the pre-existing field (so SDK / CLI / frontend code 390 that reads ``data.detail`` keeps working); ``code`` adds a stable 391 machine-readable identifier derived from the raising exception class, 392 and ``timestamp`` is server-side UTC in ISO 8601 — useful when 393 correlating client-side and server-side logs. 394 395 ``detail`` is a union because three sources feed the envelope: 396 * a plain string from legacy ``HTTPException`` / ``raise_error`` calls; 397 * a structured dict (``{"message": "...", "competition_id": ...}`` 398 or the legacy ``{"error": "..."}``) when the route wants to attach 399 machine-readable context the frontend can branch on; 400 * a list of FastAPI ``RequestValidationError`` issues, wrapped under 401 ``{"message", "issues"}`` by ``_handle_validation_error``. 402 """ 403 404 code: str 405 detail: str | dict[str, Any] | list[dict[str, Any]] 406 timestamp: datetime | None = None
Unified shape for every HTTP 4xx/5xx body produced by platform routes.
detail mirrors the pre-existing field (so SDK / CLI / frontend code
that reads data.detail keeps working); code adds a stable
machine-readable identifier derived from the raising exception class,
and timestamp is server-side UTC in ISO 8601 — useful when
correlating client-side and server-side logs.
detail is a union because three sources feed the envelope:
- a plain string from legacy
HTTPException/raise_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.
384class EvalAllowanceGrant(CtfyModel): 385 """A grant as an operator types it.""" 386 387 user_id: str = Field(min_length=1) 388 competition_id: str = Field(min_length=1) 389 #: ⚠️ ``0`` is allowed and funds nothing — an operator can create the 390 #: row before deciding the amount. Negative is not: it would read as 391 #: a debt and ``has_room`` would refuse it anyway, so the form says 392 #: so instead of storing a number nothing can mean. 393 cap_usd: float = Field(ge=0.0) 394 note: str = Field(default="", max_length=200)
A grant as an operator types it.
348class EvalAllowanceRow(CtfyModel): 349 """One grant, as an operator or its holder reads it. 350 351 ⚠️ ``remaining_usd`` and ``exhausted`` are **computed here**, on a 352 wire model, from the same arithmetic the dispatch uses. A client 353 subtracting for itself would eventually disagree with the server 354 about whether a run may start — and it is the server's refusal that 355 decides. 356 """ 357 358 user_id: str = "" 359 #: The account's own name, so an operator's list is readable without 360 #: a second lookup per row. 361 display_name: str = "" 362 competition_id: str = "" 363 competition_title: str = "" 364 cap_usd: float = 0.0 365 spent_usd: float = 0.0 366 granted_by_user_id: str = "" 367 granted_at: str = "" 368 note: str = "" 369 370 @computed_field 371 @property 372 def remaining_usd(self) -> float: 373 """⚠️ Clamped at zero. An overshoot is real and stays in 374 ``spent_usd``; a negative remainder invites a reader to add it to 375 the next grant.""" 376 return Allowance(cap_usd=self.cap_usd, spent_usd=self.spent_usd).remaining_usd 377 378 @computed_field 379 @property 380 def exhausted(self) -> bool: 381 return not Allowance(cap_usd=self.cap_usd, spent_usd=self.spent_usd).has_room
One grant, as an operator or its holder reads it.
⚠️ remaining_usd and exhausted are computed here, on a
wire model, from the same arithmetic the dispatch uses. A client
subtracting for itself would eventually disagree with the server
about whether a run may start — and it is the server's refusal that
decides.
370 @computed_field 371 @property 372 def remaining_usd(self) -> float: 373 """⚠️ Clamped at zero. An overshoot is real and stays in 374 ``spent_usd``; a negative remainder invites a reader to add it to 375 the next grant.""" 376 return Allowance(cap_usd=self.cap_usd, spent_usd=self.spent_usd).remaining_usd
⚠️ Clamped at zero. An overshoot is real and stays in
spent_usd; a negative remainder invites a reader to add it to
the next grant.
1489class EvalArtifactBucket(CtfyModel): 1490 """One column of the archive-size histogram.""" 1491 1492 label: str = "" 1493 runs: int = 0
One column of the archive-size histogram.
1496class EvalArtifactReport(CtfyModel): 1497 """How much disk the collected agent archives are using. 1498 1499 ``docs/agent-artifact-plan.md`` §7 sized the archive from one half of 1500 it: the session half was measured, the working-directory half never 1501 was, and it is unbounded by construction. Both the per-archive cap 1502 and the retention window were chosen against that gap — and the 1503 retention default is *forever*, so nothing reclaims an archive until 1504 somebody sets one. This is the number that turns those into decisions 1505 somebody can make. 1506 1507 ⚠️ **``by_status`` is the reason the headline is what it is.** Zero 1508 stored bytes is four different facts — nobody has run an agent, this 1509 deployment has no bucket (``not_configured``), collection is failing 1510 (``capture_failed`` / ``upload_failed``), or a window reclaimed them 1511 (``reclaimed``) — and they read identically as a single ``0``. The 1512 same shape the registry usage report needed its scan receipt for. 1513 """ 1514 1515 stored_runs: int = 0 1516 stored_bytes: int = 0 1517 largest_bytes: int = 0 1518 buckets: list[EvalArtifactBucket] = Field(default_factory=list) 1519 #: ``{artifact_status: runs}`` over **every** run, archive or not. 1520 by_status: dict[str, int] = Field(default_factory=dict) 1521 #: The window in force, echoed so the page does not restate the 1522 #: setting resolver's precedence (DB > env > default) in a browser. 1523 #: ``0`` is forever, and forever is the shipped default. 1524 retention_s: int = 0 1525 #: Whether this deployment can keep an archive at all. ⚠️ Server-side 1526 #: and defaulting **False** — a client reading it must never be the 1527 #: one deciding, and an older server must not claim a bucket it 1528 #: never reported. 1529 storage_configured: bool = False
How much disk the collected agent archives are using.
docs/agent-artifact-plan.md §7 sized the archive from one half of
it: the session half was measured, the working-directory half never
was, and it is unbounded by construction. Both the per-archive cap
and the retention window were chosen against that gap — and the
retention default is forever, so nothing reclaims an archive until
somebody sets one. This is the number that turns those into decisions
somebody can make.
⚠️ by_status is the reason the headline is what it is. Zero
stored bytes is four different facts — nobody has run an agent, this
deployment has no bucket (not_configured), collection is failing
(capture_failed / upload_failed), or a window reclaimed them
(reclaimed) — and they read identically as a single 0. The
same shape the registry usage report needed its scan receipt for.
424class EvalEntryRequest(CtfyModel): 425 """An entrant putting their own agent into one competition. 426 427 ⚠️ **Three ids, a sample count and two limits it may only tighten.** 428 The operator's submit request also carries ``name``, ``tier`` and 429 ``max_cost_usd``; none of them belongs to a caller who is not the 430 operator. ``tier`` is the one that matters — a field a client can set 431 is a field a client can forge, and a forged ``official`` would put a 432 self-run campaign on the default public board. The route stamps it, 433 so the absence here is a second layer rather than the only one. 434 435 ⚠️ The two that *are* here are safe for the opposite reason: 436 ``_harness_timeout`` clamps the wall clock to the deployment's own, 437 so naming one can only shorten a run. An entrant knows something the 438 operator does not — that their agent gives up after ten minutes, or 439 that it needs every second it can get on this set — and the value 440 that number decides is their own result. 441 """ 442 443 harness_id: str = Field(min_length=1) 444 model_id: str = Field(min_length=1) 445 competition_id: str = Field(min_length=1) 446 #: ⚠️ Clamped by the route, not trusted from here. pass@k multiplies 447 #: the fan-out, so this is the field that turns one submission into 448 #: an unbounded number of container runs on somebody else's nodes. 449 num_samples: int = Field(default=1, ge=1) 450 #: ⚠️ ``0`` inherits the deployment's setting on both, never 451 #: "unbounded" — the reading ``max_cost_usd`` takes on the operator's 452 #: form and the opposite of what is safe here. The wall clock is 453 #: additionally clamped at dispatch, so this can shorten a run and 454 #: never lengthen one. 455 timeout_s: int = Field(default=0, ge=0) 456 max_turns: int = Field(default=0, ge=0)
An entrant putting their own agent into one competition.
⚠️ Three ids, a sample count and two limits it may only tighten.
The operator's submit request also carries name, tier and
max_cost_usd; none of them belongs to a caller who is not the
operator. tier is the one that matters — a field a client can set
is a field a client can forge, and a forged official would put a
self-run campaign on the default public board. The route stamps it,
so the absence here is a second layer rather than the only one.
⚠️ The two that are here are safe for the opposite reason:
_harness_timeout clamps the wall clock to the deployment's own,
so naming one can only shorten a run. An entrant knows something the
operator does not — that their agent gives up after ten minutes, or
that it needs every second it can get on this set — and the value
that number decides is their own result.
675class EvalLeaderboard(CtfyModel): 676 """The full leaderboard: ranked model standings + the dimensions available.""" 677 678 competition_id: str = "" 679 challenge_count: int = 0 680 #: the single corpus version all runs share (reproducibility header); 681 #: blank when runs span mixed corpus revisions. 682 corpus_sha: str = "" 683 dimensions: list[str] = Field(default_factory=list) 684 models: list[ModelStanding] = Field(default_factory=list) 685 #: vendor scorecards (pooled across each vendor's models); unattributed 686 #: models (blank vendor) are omitted. 687 vendors: list[VendorStanding] = Field(default_factory=list) 688 #: harness scorecards (pooled across each harness's runs) — the harness 689 #: comparison axis. The blank harness id is the built-in ctfy harness. 690 harnesses: list[HarnessStanding] = Field(default_factory=list) 691 #: per-(model, harness) cross-tab cells — the apples-to-apples view that 692 #: isolates the harness effect on a fixed model. Empty unless ≥2 harnesses. 693 cross: list[HarnessModelCell] = Field(default_factory=list) 694 #: run activity + run-level solve rate bucketed by UTC day (chronological). 695 trend: list[EvalTrendPoint] = Field(default_factory=list)
The full leaderboard: ranked model standings + the dimensions available.
1566class EvalRunCoverage(CtfyModel): 1567 """How much of the range this run actually reached. 1568 1569 ``#1582``'s first question: *which planted vulnerabilities did the 1570 agent find, and which did it walk past*. The Reporting grade cannot 1571 answer it — it is graded against the team's own captures, so it 1572 measures whether what was written is true and says nothing about how 1573 much there was to write about. 1574 1575 ⚠️ **Two numbers, never one.** Coverage and fidelity move 1576 independently, and blending them is what made a run that found three 1577 of thirty-seven publish a 100. This model is the coverage half and 1578 carries no score at all. 1579 1580 ⚠️ **Per run, and that is the whole reason it is addressed by a run 1581 id.** An eval entrant's team is one synthetic team per 1582 ``(owner, harness, model)``, so a team-scoped reading pools every 1583 sample the agent has ever run — which is the second defect in the 1584 same issue. 1585 """ 1586 1587 run_id: str = "" 1588 challenge_id: str = "" 1589 #: Every question the challenge declares, in the spec's own order. 1590 #: The **spec** decides the rows: a list derived from what was found 1591 #: has a success in every entry and cannot report a gap. 1592 rows: list[QuestionCoverageRow] = Field(default_factory=list) 1593 captured: int = 0 1594 missed: int = 0 1595 #: Questions no agent could have been credited for in this 1596 #: competition's format. ⚠️ A finding about the **range**, never about 1597 #: the model — counting these as missed reports the format's shape as 1598 #: the agent's failure. 1599 uncreditable: int = 0 1600 #: Whether the report was the only surface open, which is what makes 1601 #: ``uncreditable`` non-zero. Published so a reader is not left 1602 #: inferring the competition's mode from a count. 1603 report_only: bool = False 1604 #: Whether a Reporting grade was found for this run's team on this 1605 #: range. ⚠️ **Defaults ``False``**, and the pair with the count below 1606 #: is the point: ``found_not_described: 0`` means *nothing was walked 1607 #: past silently* only when this is ``True``. With no grade the same 1608 #: zero means *nobody has judged the document yet*, and the two send 1609 #: a reader to opposite places — ``ReportClaimsPage.read``'s rule, one 1610 #: feature over. 1611 graded: bool = False 1612 #: Questions this run captured that the graded report never mentions. 1613 #: 1614 #: ⚠️ **Not the grade's own ``not_mentioned``.** That one counts over 1615 #: ``build_fact_set``'s set, which is the team's *lifetime* captures 1616 #: on the range — and an eval entrant is one synthetic team per 1617 #: ``(owner, harness, model)``, so it pools every sample the agent 1618 #: ever ran. This is its restriction to what *this* run found, which 1619 #: is the only version that answers for a run. 1620 found_not_described: int = 0 1621 #: ⚠️ **Defaults ``False``, and the direction is deliberate.** An 1622 #: empty ``rows`` means either *the catalog no longer has this 1623 #: challenge* or *it declares nothing*, and those get opposite 1624 #: readings — the first makes the whole coverage unanswerable. The 1625 #: safe default is the one that refuses to publish ``0 of 0`` as a 1626 #: clean sheet. 1627 challenge_in_catalog: bool = False
How much of the range this run actually reached.
#1582's first question: which planted vulnerabilities did the
agent find, and which did it walk past. The Reporting grade cannot
answer it — it is graded against the team's own captures, so it
measures whether what was written is true and says nothing about how
much there was to write about.
⚠️ Two numbers, never one. Coverage and fidelity move independently, and blending them is what made a run that found three of thirty-seven publish a 100. This model is the coverage half and carries no score at all.
⚠️ Per run, and that is the whole reason it is addressed by a run
id. An eval entrant's team is one synthetic team per
(owner, harness, model), so a team-scoped reading pools every
sample the agent has ever run — which is the second defect in the
same issue.
189class EvalTrendPoint(CtfyModel): 190 """Eval activity + run-level performance for one UTC day. 191 192 ``solve_rate`` here is *run-level* (fully-solved runs ÷ runs that day), 193 a different lens from the per-challenge best-of-k rate in the standings: 194 it tracks how the fleet is performing over time, not cumulative coverage. 195 """ 196 197 period: str = "" # UTC calendar day, YYYY-MM-DD 198 runs: int = 0 199 fully_solved: int = 0 200 solve_rate: float = 0.0 201 cost_usd: float = 0.0
Eval activity + run-level performance for one UTC day.
solve_rate here is run-level (fully-solved runs ÷ runs that day),
a different lens from the per-challenge best-of-k rate in the standings:
it tracks how the fleet is performing over time, not cumulative coverage.
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.
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.
1361class GrantAuthoringReviewerRequest(CtfyModel): 1362 """Body for ``POST /admin/competitions/{id}/authoring-reviewers`` — 1363 the target by id or (case-insensitive) email, the competition-admin 1364 grant's rule. No ``role``: this grant has exactly one.""" 1365 1366 user_id: str = "" 1367 email: str = ""
Body for POST /admin/competitions/{id}/authoring-reviewers —
the target by id or (case-insensitive) email, the competition-admin
grant's rule. No role: this grant has exactly one.
1370class GrantCompetitionAdminRequest(CtfyModel): 1371 """Body for ``PUT /admin/competitions/{id}/admins`` — resolve the 1372 target user by id or (case-insensitive) email. 1373 1374 ``role`` picks the grant tier: ``admin`` (full organiser control of 1375 this event) or ``reviewer`` (roster read + eligibility verdicts + 1376 export, nothing that removes a team). Defaults to ``admin`` so an 1377 older client that omits it keeps the prior behaviour. 1378 1379 ``reason`` is an optional organiser note carried onto the invitation 1380 row (and, for a fresh invitation, into the mail). Only the 1381 participation-invite routes read it; the admin-grant routes that 1382 share this body ignore it.""" 1383 1384 user_id: str = "" 1385 email: str = "" 1386 role: CompetitionGrantRole = "admin" 1387 reason: str = Field(default="", max_length=500)
Body for PUT /admin/competitions/{id}/admins — resolve the
target user by id or (case-insensitive) email.
role picks the grant tier: admin (full organiser control of
this event) or reviewer (roster read + eligibility verdicts +
export, nothing that removes a team). Defaults to admin so an
older client that omits it keeps the prior behaviour.
reason is an optional organiser note carried onto the invitation
row (and, for a fresh invitation, into the mail). Only the
participation-invite routes read it; the admin-grant routes that
share this body ignore it.
171class HarnessModelCell(CtfyModel): 172 """One ``(model, harness)`` pair's solve stats — a cross-tab cell. 173 174 The apples-to-apples lens the pooled rollups can't give: holding the 175 model fixed and varying the harness isolates the harness's effect on 176 that model (e.g. ``gpt-5`` solves 80% under one harness, 60% under 177 another). Display labels are resolved frontend-side from the model / 178 harness standings, so the cell carries only ids + stats. 179 """ 180 181 model_id: str = "" 182 #: harness registry id; blank = the built-in ctfy harness. 183 harness_id: str = "" 184 challenges_attempted: int = 0 185 challenges_solved: int = 0 186 solve_rate: float = 0.0
One (model, harness) pair's solve stats — a cross-tab cell.
The apples-to-apples lens the pooled rollups can't give: holding the
model fixed and varying the harness isolates the harness's effect on
that model (e.g. gpt-5 solves 80% under one harness, 60% under
another). Display labels are resolved frontend-side from the model /
harness standings, so the cell carries only ids + stats.
139class HarnessStanding(CtfyModel): 140 """One harness's aggregate scorecard across all the models it drove. 141 142 The harness's runs are pooled across models: a challenge counts as 143 *solved* if **any** run under the harness solved it. Like the vendor 144 rollup this ranks harness *plus its model fleet*, not the harness in 145 isolation — a harness paired with stronger models scores higher. 146 """ 147 148 rank: int = 0 149 #: harness registry id; blank = the built-in ctfy harness. 150 harness_id: str = "" 151 display_name: str = "" 152 #: distinct models evaluated under this harness. 153 models: int = 0 154 challenges_attempted: int = 0 155 challenges_solved: int = 0 156 solve_rate: float = 0.0 157 questions_solved: int = 0 158 total_tokens: int = 0 159 #: estimated USD cost summed across the harness's runs (0 when unpriced). 160 cost_usd: float = 0.0 161 #: Whose agent this is: author, version, source, digest. 162 #: 163 #: ⚠️ ``None`` for the platform's own harnesses — they have no owner, 164 #: so there is no author to link to. This board is *entirely* agents, 165 #: which is what makes the attribution matter more here than on a 166 #: mixed board: a comparison table whose subject is a slug compares 167 #: nothing a reader can go and check. 168 agent: AgentEntrantInfo | None = None
One harness's aggregate scorecard across all the models it drove.
The harness's runs are pooled across models: a challenge counts as solved if any run under the harness solved it. Like the vendor rollup this ranks harness plus its model fleet, not the harness in isolation — a harness paired with stronger models scores higher.
222class HarnessTags(CtfyModel): 223 """What a registry publishes under one harness image's repository. 224 225 ``image`` is echoed back because the caller asked by harness id and 226 the answer is about the *repository* that id currently points at — 227 without it, a reader cannot tell which reference was looked up. 228 """ 229 230 image: str = "" 231 #: Tags, in the order the registry gave them, capped by 232 #: ``ctfy.server.registry_tags.MAX_TAGS``. Empty is a real answer: a 233 #: repository with nothing published. 234 tags: list[str] = Field(default_factory=list)
What a registry publishes under one harness image's repository.
image is echoed back because the caller asked by harness id and
the answer is about the repository that id currently points at —
without it, a reader cannot tell which reference was looked up.
237class HarnessUpdate(CtfyModel): 238 """The fields an owner may change on their own registered agent. 239 240 ⚠️ **A dedicated model, never ``HarnessState``.** The registration 241 route takes the state model and then overwrites five fields it 242 refuses to read from the request — ``owner_user_id`` and 243 ``review_status`` among them, because a field a client can set is a 244 field a client can forge. An update route that took the same model 245 would have to repeat that list correctly forever; here the forbidden 246 fields are simply *unrepresentable*, so the next column added to 247 ``HarnessState`` is not silently editable. 248 249 ⚠️ **Every field optional, and absence means "leave it".** Read with 250 ``model_fields_set``, never ``is not None``: an editor that saves one 251 field at a time and an absent key read as an empty value blanks the 252 rest — the trap the registration-edit and changelog routes both 253 document. 254 255 ``source_url`` keeps its ``WebUrl`` type, so the allow-list that 256 guards every address reaching an ``href`` applies here too. It is 257 rendered on a public leaderboard. 258 """ 259 260 display_name: str | None = None 261 #: ⚠️ Changing this resets review — see the route. It is the reason 262 #: the field is editable at all: without it a new version of an 263 #: agent means delete-and-re-register, which throws away the name, 264 #: the notes and the row's history along with the verdict. 265 image: str | None = None 266 version: str | None = None 267 notes: str | None = None 268 source_url: WebUrl | None = None 269 active: bool | None = None
The fields an owner may change on their own registered agent.
⚠️ A dedicated model, never HarnessState. The registration
route takes the state model and then overwrites five fields it
refuses to read from the request — owner_user_id and
review_status among them, because a field a client can set is a
field a client can forge. An update route that took the same model
would have to repeat that list correctly forever; here the forbidden
fields are simply unrepresentable, so the next column added to
HarnessState is not silently editable.
⚠️ Every field optional, and absence means "leave it". Read with
model_fields_set, never is not None: an editor that saves one
field at a time and an absent key read as an empty value blanks the
rest — the trap the registration-edit and changelog routes both
document.
source_url keeps its WebUrl type, so the allow-list that
guards every address reaching an href applies here too. It is
rendered on a public leaderboard.
19class HealthResponse(CtfyModel): 20 status: str = "ok" 21 hostname: str = "" 22 running_instances: int = 0 23 capacity: int = 0 24 # Server's installed ``ctfy`` version. Lets the SDK/CLI flag a 25 # client/server skew off the probe they already make — no extra 26 # round trip. Defaults to "" so older servers (and the model's own 27 # default construction) stay valid. 28 version: str = "" 29 # Deployment tier + label, carried on the same probe so the CLI can 30 # warn an operator they're pointed at a non-production deployment (and 31 # add friction to destructive commands on prod) without a second 32 # round trip. ``prod`` is the fail-safe default. 33 environment: Environment = "prod" 34 environment_label: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
131class ImageConsistencyRow(CtfyModel): 132 """One ``repository:tag`` the fleet disagrees on — the wire shape of 133 :class:`ctfy.core.image_consistency.ImageDivergence`. 134 135 ``by_digest`` maps each distinct digest to the node ids holding it, so 136 the admin page can render "s2, s3 hold ff2cb7e…; cb holds f5dcfb…" 137 without re-deriving anything. ``digest_count`` / ``node_count`` are 138 ``computed_field``s — this is a wire model reaching a client, the axis 139 :class:`~ctfy.core.state.models.TeamState`'s ``@property`` sits on the 140 other side of. 141 """ 142 143 ref: str 144 by_digest: dict[str, list[str]] 145 146 @computed_field 147 @property 148 def digest_count(self) -> int: 149 return len(self.by_digest) 150 151 @computed_field 152 @property 153 def node_count(self) -> int: 154 return sum(len(nodes) for nodes in self.by_digest.values())
One repository:tag the fleet disagrees on — the wire shape of
ctfy.core.image_consistency.ImageDivergence.
by_digest maps each distinct digest to the node ids holding it, so
the admin page can render "s2, s3 hold ff2cb7e…; cb holds f5dcfb…"
without re-deriving anything. digest_count / node_count are
computed_fields — this is a wire model reaching a client, the axis
~ctfy.core.state.models.TeamState's @property sits on the
other side of.
265class InboxAnnouncement(CtfyModel): 266 """Announcement projected onto the Inbox surface. 267 268 Carries the same fields as :class:`AnnouncementInfo` plus a 269 per-user ``is_read`` flag derived from the 270 ``announcement_reads`` table. The frontend renders unread rows 271 prominently and lets the user mark them read via 272 ``POST /me/announcements/{id}/read``. 273 """ 274 275 id: str 276 title: str 277 body: str 278 severity: AnnouncementSeverity 279 starts_at: datetime | None = None 280 ends_at: datetime | None = None 281 created_at: datetime | None = None 282 updated_at: datetime | None = None 283 created_by: str 284 created_by_name: str 285 is_read: bool = False
Announcement projected onto the Inbox surface.
Carries the same fields as AnnouncementInfo plus a
per-user is_read flag derived from the
announcement_reads table. The frontend renders unread rows
prominently and lets the user mark them read via
POST /me/announcements/{id}/read.
218class InboxCaptainRequest(CtfyModel): 219 """Pending join request against any team the calling user 220 captains. Frontend renders an Approve / Reject pair pointing at 221 ``/competitions/{competition_id}/invites/{invite_id}/approve|reject``. 222 """ 223 224 id: str 225 competition_id: str 226 competition_title: str 227 team_id: str 228 team_name: str 229 requester_user_id: str 230 requester_display_name: str 231 created_at: datetime | None = None
Pending join request against any team the calling user
captains. Frontend renders an Approve / Reject pair pointing at
/competitions/{competition_id}/invites/{invite_id}/approve|reject.
234class InboxCompetitionInvite(CtfyModel): 235 """A standing invitation to participate in a PRIVATE competition the 236 calling user has been granted but not yet joined. 237 238 Unlike the team invites above this isn't accept/decline — the 239 allowlist row is a standing permission, so the frontend renders a 240 "Register" CTA linking to ``/competitions/{competition_id}/team``. 241 Drops off the inbox once the user registers (or the comp ends / is 242 flipped back to public). 243 """ 244 245 competition_id: str 246 competition_title: str 247 competition_phase: Literal["upcoming", "running", "past"] 248 invited_by_user_id: str = "" 249 invited_by_display_name: str = "" 250 invited_at: datetime | None = None 251 #: Why the "Register" CTA above cannot be offered yet, or ``""``. 252 #: 253 #: ⚠️ **The row stays on the inbox when this is set.** Filtering the 254 #: card out is the shorter fix and it tells the invitee nothing: 255 #: "invited, waiting on the organiser" and "never invited" would 256 #: render identically, and the first is the one somebody is about to 257 #: ask about. The card explains instead, and the button is what goes. 258 #: 259 #: ⚠️ **``""`` is the open answer**, so a server too old to send the 260 #: field leaves the button exactly where it was — every gate here 261 #: fails open, and a wrong "blocked" takes a live registration away. 262 blocked_reason: InvitationBlock = ""
A standing invitation to participate in a PRIVATE competition the calling user has been granted but not yet joined.
Unlike the team invites above this isn't accept/decline — the
allowlist row is a standing permission, so the frontend renders a
"Register" CTA linking to /competitions/{competition_id}/team.
Drops off the inbox once the user registers (or the comp ends / is
flipped back to public).
185class InboxIncomingInvite(CtfyModel): 186 """Pending direct invite where the calling user is the named target. 187 188 The frontend renders an Accept / Decline pair pointing at 189 ``/competitions/{competition_id}/invites/{invite_id}/accept|decline``. 190 """ 191 192 id: str 193 competition_id: str 194 competition_title: str 195 team_id: str 196 team_name: str 197 captain_user_id: str 198 captain_display_name: str 199 created_at: datetime | None = None
Pending direct invite where the calling user is the named target.
The frontend renders an Accept / Decline pair pointing at
/competitions/{competition_id}/invites/{invite_id}/accept|decline.
202class InboxOutgoingRequest(CtfyModel): 203 """Pending join request the calling user opened — surfaces so the 204 requester can see "did the captain see this yet?" without 205 refreshing the team page. 206 """ 207 208 id: str 209 competition_id: str 210 competition_title: str 211 team_id: str 212 team_name: str 213 captain_user_id: str 214 captain_display_name: str 215 created_at: datetime | None = None
Pending join request the calling user opened — surfaces so the requester can see "did the captain see this yet?" without refreshing the team page.
288class InboxResponse(CtfyModel): 289 """``GET /me/inbox`` payload — pending invites/requests + 290 actionable announcements grouped by the action the calling 291 user can take. 292 """ 293 294 incoming_invites: list[InboxIncomingInvite] = Field(default_factory=list) 295 outgoing_requests: list[InboxOutgoingRequest] = Field(default_factory=list) 296 captain_requests: list[InboxCaptainRequest] = Field(default_factory=list) 297 # Standing participation invites to private competitions the user 298 # hasn't joined yet (rendered with a "Register" CTA, not accept/decline). 299 competition_invites: list[InboxCompetitionInvite] = Field(default_factory=list) 300 # Site-wide announcements relevant right now (currently live + a 301 # short tail of recently-expired) with the user's read state. 302 announcements: list[InboxAnnouncement] = Field(default_factory=list) 303 # Convenience count so the sidebar can render an unread badge 304 # without re-iterating ``announcements``. 305 unread_announcement_count: int = 0
GET /me/inbox payload — pending invites/requests +
actionable announcements grouped by the action the calling
user can take.
356class InstanceAnswersResponse(CtfyModel): 357 """Every answer minted for one live instance. 358 359 ⚠️ **The one place the platform hands out groundtruth.** Everything 360 else is deliberately the other way round: the catalog redacts gated 361 prompts, ``AdminChallengeQuestions`` widens *who may read the 362 question* and says in its own docstring that it does not widen 363 *what is said*, and ``POST /admin/answer-trace`` attributes a value 364 the caller already holds without ever emitting one. This route 365 exists because an organiser debugging "the team swears they 366 submitted this" has no other way to compare, and it is scoped so 367 the cost of that is bounded and visible: 368 369 * **live instances only** — an archived record carries the digest, 370 not the plaintext, so there is nothing here to give out and the 371 route says so rather than returning an empty list that reads as 372 "no answers were minted"; 373 * **one instance per request**, never a listing, so reading the 374 whole event is N deliberate acts rather than one page; 375 * **audited** — every read emits ``instance_answers_revealed`` 376 naming the admin, and the row carries the count and no value. 377 378 A static ``answer:`` is not here either: it lives in 379 ``metadata.yaml`` and is the same for every team, so it is not a 380 per-instance fact and this is not the route that would answer it. 381 """ 382 383 instance_id: str 384 team_id: str = "" 385 challenge_id: str = "" 386 answers: list[RevealedAnswer] = Field(default_factory=list)
Every answer minted for one live instance.
⚠️ The one place the platform hands out groundtruth. Everything
else is deliberately the other way round: the catalog redacts gated
prompts, AdminChallengeQuestions widens who may read the
question and says in its own docstring that it does not widen
what is said, and POST /admin/answer-trace attributes a value
the caller already holds without ever emitting one. This route
exists because an organiser debugging "the team swears they
submitted this" has no other way to compare, and it is scoped so
the cost of that is bounded and visible:
- live instances only — an archived record carries the digest, not the plaintext, so there is nothing here to give out and the route says so rather than returning an empty list that reads as "no answers were minted";
- one instance per request, never a listing, so reading the whole event is N deliberate acts rather than one page;
- audited — every read emits
instance_answers_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.
79class InstanceInfo(CtfyModel): 80 id: str 81 challenge_id: str = "" 82 team_id: str = "" 83 # Competition the instance is scoped to. Stamped from the resolved 84 # per-comp team at start time; empty for legacy rows or unscoped 85 # admin instances. Frontend keys per-comp instance pages off this 86 # field so a user registered for two comps doesn't see comp A's 87 # instances in comp B's tab. 88 competition_id: str = "" 89 # Worker node currently hosting this instance. Empty only for 90 # records created before nodes were tracked (legacy). 91 node_id: str = "" 92 # Display names for the two ids above, so an admin table can render 93 # what it links to rather than a truncated uuid. Resolved in bulk at 94 # the route from a pre-loaded map; ``""`` means "not resolved", which 95 # is what a caller that passes no map gets and what the client 96 # already renders today (the id, truncated). 97 # 98 # ⚠️ Empty is never the id. `EntityCell` branches on name-vs-id to 99 # decide whether it has something human to show, so echoing the id 100 # here would make "we know the name" indistinguishable from "we 101 # don't" — and every row would render as though it had been resolved. 102 team_name: str = "" 103 node_name: str = "" 104 name: str = "" 105 category: ChallengeCategory | None = None 106 difficulty: str = "" 107 status: InstanceStatus = InstanceStatus.STARTING 108 started_at: float = 0.0 109 ttl: int = 0 110 expires_at: float = 0.0 111 services: list[ServiceEndpoint] = Field(default_factory=list) 112 description: str = "" 113 # Per-instance question view: same shape as ``ChallengeInfo.questions`` 114 # but additionally carries the agent-visible state. ``unlocked`` is 115 # ``False`` when the question's ``requires:`` predecessors haven't 116 # all been answered correctly yet; the UI hides the prompt for locked 117 # questions to avoid leaking route hints. ``answered_correctly`` is 118 # ``True`` once this team has captured this question on this instance. 119 questions: list[InstanceQuestionInfo] = Field(default_factory=list) 120 # The files *this* instance offers its team: the node's scan of the 121 # instance's own ``attachments/`` after templates rendered and the 122 # ``attachment-builder`` ran (``surface.provided_files``). Per-team 123 # artifacts appear here, a placeholder the builder deleted does not, 124 # and each downloads from ``/instances/{id}/attachments/{name}``. 125 # Empty until the instance is ready (#1933). Pre-launch, the 126 # challenge's ``ChallengeInfo.attachments`` is the catalog's listing. 127 attachments: list[AttachmentInfo] = Field(default_factory=list) 128 # How the platform exposes this instance to the player. Always 129 # surfaced (defaults to ``"simple"``) so the agent can branch on 130 # `if info.network_topology == "engagement": ...` without dealing 131 # with a missing field. 132 network_topology: Literal["simple", "engagement"] = "simple" 133 # Which VPN an engagement instance runs. Meaningless in simple 134 # mode, where it stays at the default. Surfaced so the UI can name 135 # the right client command and an agent can branch without 136 # downloading the config to find out what it is. 137 vpn_backend: Literal["openvpn", "wireguard"] = "openvpn" 138 # Set only when ``network_topology == "engagement"``. Carries the 139 # tunnel endpoint host:port + the URL the client downloads the 140 # config body from. ``None`` in simple mode. 141 vpn_endpoint: VpnEndpoint | None = None 142 # Landing-page hints declared in the challenge's ``metadata.yaml`` 143 # (``entry_urls:`` field, validated by META013). Surfaced verbatim 144 # to the frontend so the VPN / services panel can render a 145 # "start here" list without the player having to read the 146 # description for the canonical first URL. 147 entry_urls: list[str] = Field(default_factory=list) 148 # Which supply this box came from. ``on_demand`` is a player's own 149 # launch and behaves the way every instance always has; ``match`` is 150 # an arena gamebox an organiser provisioned, and it is a genuinely 151 # different object to its owner — it never expires, stopping it is 152 # not theirs to do, and it is the one the round loop rotates flags 153 # into. The client cannot tell them apart from `challenge_id` alone, 154 # because a player may also launch the gamebox on demand. 155 lease: Literal["on_demand", "match"] = "on_demand"
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
34class InstanceQuestionInfo(CtfyModel): 35 """One question, projected for a specific running instance. 36 37 Mirrors :class:`QuestionPublicInfo` plus per-instance state. 38 39 ⚠️ **A question the calling team can neither see nor has captured is 40 absent from the list entirely**, rather than present with a blanked 41 ``prompt`` — see ``instances._project_instance_questions`` for why 42 the id, the ``requires`` edges, ``choices`` and the row *count* are 43 each disclosure in their own right. So ``unlocked`` is ``False`` on 44 exactly one shape: a question the team has already captured whose 45 predecessor they never formally answered (a report-credited 46 sentinel), where the prompt stays empty and the row stays because it 47 is a fact about their own work. 48 """ 49 50 id: str 51 prompt: str 52 mode: str | None = None 53 choices: list[str] | None = None 54 requires: list[str] = Field(default_factory=list) 55 # ``True`` once every id in ``requires`` has been captured by the 56 # calling team on this instance. ``False`` keeps the prompt empty 57 # to avoid leaking the question text early. 58 unlocked: bool = True 59 # ``True`` once the calling team has submitted a correct answer 60 # for this question against this instance. Drives the UI checkmark. 61 answered_correctly: bool = False 62 # Wrong-attempt budget remaining for the calling team on this 63 # specific question. ``None`` when the question's mode is uncapped 64 # (dynamic free-form, or any mode the operator opted out of via 65 # ``question_attempt_caps``). The UI renders an ``X/N attempts`` 66 # badge from this on first page load and decrements it locally on 67 # each wrong submit (the submission response also carries it). 68 attempts_remaining: int | None = None 69 # The cap that ``attempts_remaining`` is being measured against — 70 # echoed so the UI can render ``X/N`` without recomputing N from 71 # the question's mode + current platform settings. 72 attempts_cap: int | None = None 73 # ``True`` for a checker question whose ``check.poll`` is on: the 74 # platform auto-verifies it in the background, so the UI shows an 75 # "auto-verify on" affordance. Never exposes the checker command. 76 auto_check: bool = False
One question, projected for a specific running instance.
Mirrors QuestionPublicInfo plus per-instance state.
⚠️ A question the calling team can neither see nor has captured is
absent from the list entirely, rather than present with a blanked
prompt — see instances._project_instance_questions for why
the id, the requires edges, choices and the row count are
each disclosure in their own right. So unlocked is False on
exactly one shape: a question the team has already captured whose
predecessor they never formally answered (a report-credited
sentinel), where the prompt stays empty and the row stays because it
is a fact about their own work.
213class InstanceRecordArtifacts(CtfyModel): 214 """Per-artifact presence flags + counts for an archived instance. 215 216 Returned alongside the full :class:`InstanceRecord` from 217 ``GET /admin/instance-records/{id}`` so the admin UI can show 218 ``Manifest / Events / Submissions / Container log`` tabs at a glance 219 without a round-trip per tab. 220 """ 221 222 has_manifest: bool = False 223 has_container_log: bool = False 224 events_count: int = 0 225 submissions_count: int = 0 226 traffic_count: int = 0 227 # Bytes on disk for the archived ``traffic.pcap``. 0 when no capture 228 # was persisted (sidecar disabled, archive disabled, fetch failed — 229 # or retention reclaimed it, which the two fields below say). The 230 # admin UI uses this both to gate the download button and to render 231 # an "X MB" hint next to it. 232 pcap_bytes: int = 0 233 #: When retention removed this archive's capture files (#1714), or 234 #: ``0`` if it never did. ⚠️ Why the counts above can be zero on an 235 #: instance that was captured: a zero beside a non-zero here is 236 #: *reclaimed*, not *never captured*. 237 capture_reclaimed_at: float = 0.0 238 capture_reclaimed_bytes: int = 0 239 #: When retention removed the rest (manifest, events, submissions, 240 #: container log), or ``0``. The ``InstanceRecord`` row is never 241 #: removed, so the record above stays whole either way. 242 archive_reclaimed_at: float = 0.0 243 archive_reclaimed_bytes: int = 0 244 #: What the node's per-instance capture cap dropped before the 245 #: capture was archived — the oldest segments. ``0`` = nothing 246 #: recorded. 247 pcap_dropped_bytes: int = 0 248 pcap_dropped_segments: int = 0
Per-artifact presence flags + counts for an archived instance.
Returned alongside the full InstanceRecord from
GET /admin/instance-records/{id} so the admin UI can show
Manifest / Events / Submissions / Container log tabs at a glance
without a round-trip per tab.
310class InstanceRecordDetail(CtfyModel): 311 record: InstanceRecordInfoDetail 312 artifacts: InstanceRecordArtifacts = Field(default_factory=InstanceRecordArtifacts)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
158class InstanceRecordInfo(CtfyModel): 159 """Archived-instance summary for the admin history list. 160 161 Mirrors :class:`ctfy.core.state.models.InstanceRecord` minus the heavy 162 fields (``spec``, ``surface``). Used by ``GET /admin/instance-records``. 163 """ 164 165 id: str 166 team_id: str = "" 167 challenge_id: str = "" 168 node_id: str = "" 169 # Same contract as ``InstanceInfo.team_name`` / ``node_name`` — see 170 # there. Declared again rather than inherited because this is a 171 # sibling class, not a subclass, so a field added to one is genuinely 172 # absent from the other. The archive needs them at least as much as 173 # the live table: it is read after the event, when the team it names 174 # is likeliest to have been deleted. 175 team_name: str = "" 176 node_name: str = "" 177 name: str = "" 178 category: ChallengeCategory | None = None 179 difficulty: str = "" 180 status: ArchivedInstanceStatus = "" 181 stop_reason: str = "" 182 error: str = "" 183 # Full node diagnostic (e.g. ``docker compose up`` stderr tail). 184 # Admin-only: this model backs ``/admin/instance-records*`` only; 185 # the player-facing ``InstanceInfo`` / ``InstanceStatusResponse`` 186 # deliberately omit it. 187 error_detail: str = "" 188 started_at: float = 0.0 189 stopped_at: float = 0.0 190 duration_s: float = 0.0 191 ttl: int = 0 192 solved: bool = False 193 attempts: int = 0 194 # Total HTTP requests captured by the mitmproxy sidecar during the 195 # instance's lifetime. 0 when the archive sink was disabled or no 196 # traffic was captured. 197 request_count: int = 0
Archived-instance summary for the admin history list.
Mirrors ctfy.core.state.models.InstanceRecord minus the heavy
fields (spec, surface). Used by GET /admin/instance-records.
200class InstanceRecordInfoDetail(InstanceRecordInfo): 201 """Full record — includes spec + surface, returned by detail endpoint.""" 202 203 spec: dict[str, Any] = Field(default_factory=dict) 204 surface: AttackSurface | None = None 205 artifact_dir: str = "" 206 #: Where the start took its built services from (#1852): ``frozen``, 207 #: ``built: <why the frozen images were not used>``, or ``""``. Read 208 #: off the *live* instance; an archived record leaves it ``""``, which 209 #: is "not recorded", never "built". 210 image_source: str = ""
Full record — includes spec + surface, returned by detail endpoint.
412class InstanceStatusResponse(CtfyModel): 413 id: str 414 status: InstanceStatus = InstanceStatus.STARTING 415 attack_surface: AttackSurface | None = None 416 error: str = "" 417 # CA volume for the per-instance mitmproxy. Operators trust this CA 418 # if they want to MITM HTTPS; HTTP traffic is captured transparently 419 # without it. 420 cert_volume: str = "" 421 #: Seconds this platform will keep waiting for a start before it 422 #: calls the instance ERROR — so a client waiting for readiness can 423 #: adopt the deadline instead of inventing a shorter one. 424 #: ⚠️ ``0`` means *this server did not say* (it predates the field), 425 #: never *no time at all*: a client reading it as a deadline would 426 #: give up instantly on every older deployment. 427 start_budget_s: int = 0
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
293class InstanceStorageReport(CtfyModel): 294 """``GET /admin/instance-storage``: both per-instance stores on disk. 295 296 ⚠️ **Two stores on possibly two hosts, so two sources.** The archive 297 is measured by the platform on its own disk; each node measures its 298 own captures and sends the reading on its heartbeat. A node whose row 299 has no ``report`` has not been measured — a different fact from an 300 empty store, and the page says which. 301 """ 302 303 archive: ArchiveStoreReport | None = None 304 captures: list[NodeCaptureStoreRow] = Field(default_factory=list) 305 capture_retention_s: int = 0 306 archive_retention_s: int = 0 307 capture_max_bytes: int = 0
GET /admin/instance-storage: both per-instance stores on disk.
⚠️ Two stores on possibly two hosts, so two sources. The archive
is measured by the platform on its own disk; each node measures its
own captures and sends the reading on its heartbeat. A node whose row
has no report has not been measured — a different fact from an
empty store, and the page says which.
480class InstanceTrafficPage(CtfyModel): 481 """One poll's worth of captured flows, plus where to resume. 482 483 ``cursor`` is opaque: it is this platform's private encoding of how 484 far each capture file has been read, and nothing outside 485 ``ctfy.server.traffic_tail`` may parse it. A client stores whatever 486 it was handed and sends it back. 487 488 ⚠️ **An empty cursor means "start over", so a client must not invent 489 one.** Sending `""` re-reads the whole capture — which is exactly 490 the cost the tail read exists to remove, arriving through the client 491 rather than through the server. 492 """ 493 494 flows: FlowRecords = Field(default_factory=list) 495 cursor: str = "" 496 # A bound was hit and more is already on disk. A client catching up 497 # should poll again immediately rather than waiting out its 498 # interval — the alternative is a defender watching an attack 499 # through a window that fills at one page per interval. 500 truncated: bool = False 501 #: ``""`` / ``"on"`` / ``"off"`` — whether a proxy sidecar exists for 502 #: this instance at all. 503 #: 504 #: ⚠️ **Tri-state, and ``""`` is the default, because the other two 505 #: values are claims.** An empty page means "nothing yet" when a 506 #: sidecar is running and "nothing, ever" when none is, and those 507 #: render as the same empty table — which reads as the feature being 508 #: broken. But a node that never answered has told us neither, and a 509 #: boolean has nowhere to put that. Same rule as 510 #: ``ScoreboardEntry.review_status`` defaulting to ``""`` rather 511 #: than to ``approved``. 512 capture: str = "" 513 #: What redaction did to the flows above, for the surface that 514 #: composed them. 515 #: 516 #: ⚠️ **``""`` is *this surface did not say*, and it is the default 517 #: because the other three are claims.** `raw_traffic_page` is the 518 #: forensic record and redacts nothing on purpose, so 519 #: ``nothing_to_redact`` there would be false in the costly 520 #: direction — it is not that there was nothing to remove, it is 521 #: that this surface does not remove it. The failure paths in 522 #: `instance_traffic._page` never reached a redactor at all and say 523 #: the same. Same reading as ``capture`` above, and 524 #: ``MyEvalRunRow.usage_source``'s recorded correction. 525 #: 526 #: ⚠️ **A client reads ``unscrubbable`` positively** — an older 527 #: server sends no field at all, which must not read as a refusal. 528 redaction: RedactionState = "" 529 #: Which secrets the floor could not replace, **by name**: a 530 #: question id, or a family's name. ⚠️ Never the values — a refusal 531 #: that quotes the secret is the leak it refused to commit. 532 #: Non-empty only beside ``redaction == "unscrubbable"``, and it is 533 #: what makes that refusal actionable rather than a dead end. 534 unscrubbable: list[str] = Field(default_factory=list)
One poll's worth of captured flows, plus where to resume.
cursor is opaque: it is this platform's private encoding of how
far each capture file has been read, and nothing outside
ctfy.server.traffic_tail may parse it. A client stores whatever
it was handed and sends it back.
⚠️ An empty cursor means "start over", so a client must not invent
one. Sending "" re-reads the whole capture — which is exactly
the cost the tail read exists to remove, arriving through the client
rather than through the server.
459class InstanceTrafficWitnesses(CtfyModel): 460 """One poll's worth of witnesses, plus where to resume. 461 462 Same cursor as :class:`InstanceTrafficPage` — a per-file byte 463 offset, opaque, and the two readers advance independently because 464 each holds its own. 465 """ 466 467 witnesses: list[TrafficWitness] = Field(default_factory=list) 468 cursor: str = "" 469 #: ⚠️ **A ceiling was hit, so this answer is incomplete rather than 470 #: empty**, and those are opposite advice about a capture. The round 471 #: loop reads it as *unknown*; reading it as "nobody was there" 472 #: would manufacture an accusation out of a budget. 473 truncated: bool = False 474 #: ``""`` / ``"on"`` / ``"off"``, exactly as on the flow page and 475 #: for the same reason: "quiet" and "nothing is watching" render as 476 #: the same empty answer, and only the node can tell them apart. 477 capture: str = ""
One poll's worth of witnesses, plus where to resume.
Same cursor as InstanceTrafficPage — a per-file byte
offset, opaque, and the two readers advance independently because
each holds its own.
83class InstitutionCreate(CtfyModel): 84 """Body for creating or replacing one row. 85 86 ⚠️ **``domain`` is the identity, so this is an upsert rather than a 87 POST-then-PATCH pair.** Renaming one is a delete and a create, which 88 is what the key being the name means; a PATCH that could move it 89 would be a second way to reach a decision the delete makes 90 deliberate. 91 92 ⚠️ **``url`` is ``WebUrl``**, the allow-list type, because it reaches 93 an ``href`` on a page anybody can open. 94 """ 95 96 domain: str = Field(min_length=1, max_length=253) 97 names: dict[str, str] = Field(default_factory=dict) 98 short_name: str = Field(default="", max_length=32) 99 kind: InstitutionKind = "other" 100 url: WebUrl | None = None
Body for creating or replacing one row.
⚠️ domain is the identity, so this is an upsert rather than a
POST-then-PATCH pair. Renaming one is a delete and a create, which
is what the key being the name means; a PATCH that could move it
would be a second way to reach a decision the delete makes
deliberate.
⚠️ url is WebUrl, the allow-list type, because it reaches
an href on a page anybody can open.
17class InstitutionInfo(CtfyModel): 18 """One named domain, as an operator's page reads it. 19 20 ⚠️ **``names`` crosses as a map, resolved in the browser.** Copy is 21 localised and the language is a client-side preference switched 22 without a reload — the changelog's decision, for the same reason: a 23 server that picked one would publish whichever language the request 24 happened to carry into a table the operator curates in both. 25 """ 26 27 domain: str = "" 28 names: dict[str, str] = Field(default_factory=dict) 29 short_name: str = "" 30 kind: InstitutionKind = "other" 31 url: str = "" 32 created_at: AwareDatetime | None = None 33 updated_at: AwareDatetime | None = None 34 #: How many accounts hold a **proved** address here. 35 #: 36 #: ⚠️ **Composed by the route, never stored**: it is a measurement of 37 #: the deployment's own people and would be stale the moment anybody 38 #: verified an address. `InstitutionSuggestion.holders` is the same 39 #: number over the domains nobody has named, which is why they share 40 #: one producer and one query. 41 #: 42 #: ⚠️ **``0`` is a real answer** — "this domain is named and nobody 43 #: here is at it" is exactly what an operator who over-named wants to 44 #: see. The cost of that decision is that a producer which forgets to 45 #: fill it publishes the same ``0``; it is the direction somebody 46 #: reports, on a row whose whole point is a count. 47 holders: int = 0 48 49 @computed_field 50 @property 51 def warning(self) -> str: 52 """Why this stored domain is probably a mistake, or ``""``. 53 54 ⚠️ **Derived rather than stored, and it stays on the row rather 55 than being said once at the write.** `domain_warning` is 56 advisory by construction — a deployment run by one university 57 may mean ``edu.cn`` exactly as typed — so refusing it is wrong 58 and saying it only in the create response means an operator who 59 made the mistake last month never hears about it. A 60 `computed_field` is the wire's half of that decision; this is a 61 wire model, so the state model beside it deliberately has no 62 such column. 63 """ 64 return domain_warning(self.domain)
One named domain, as an operator's page reads it.
⚠️ names crosses as a map, resolved in the browser. Copy is
localised and the language is a client-side preference switched
without a reload — the changelog's decision, for the same reason: a
server that picked one would publish whichever language the request
happened to carry into a table the operator curates in both.
49 @computed_field 50 @property 51 def warning(self) -> str: 52 """Why this stored domain is probably a mistake, or ``""``. 53 54 ⚠️ **Derived rather than stored, and it stays on the row rather 55 than being said once at the write.** `domain_warning` is 56 advisory by construction — a deployment run by one university 57 may mean ``edu.cn`` exactly as typed — so refusing it is wrong 58 and saying it only in the create response means an operator who 59 made the mistake last month never hears about it. A 60 `computed_field` is the wire's half of that decision; this is a 61 wire model, so the state model beside it deliberately has no 62 such column. 63 """ 64 return domain_warning(self.domain)
Why this stored domain is probably a mistake, or "".
⚠️ Derived rather than stored, and it stays on the row rather
than being said once at the write. domain_warning is
advisory by construction — a deployment run by one university
may mean edu.cn exactly as typed — so refusing it is wrong
and saying it only in the create response means an operator who
made the mistake last month never hears about it. A
computed_field is the wire's half of that decision; this is a
wire model, so the state model beside it deliberately has no
such column.
67class InstitutionPage(CtfyModel): 68 """Every named domain. 69 70 ⚠️ **Unpaged, and that is why it carries no ``limit``.** The 71 population is what one operator typed for one deployment and the 72 page is a table they curate, not a feed — `/admin/changelog`'s 73 reasoning. A bare list route that *did* take a window would owe a 74 total, which is what `test_a_list_route_says_how_many` is for; this 75 one takes none, so the count is here for the heading rather than to 76 say whether the answer was cut. 77 """ 78 79 items: list[InstitutionInfo] = Field(default_factory=list) 80 total: int = 0
Every named domain.
⚠️ Unpaged, and that is why it carries no limit. The
population is what one operator typed for one deployment and the
page is a table they curate, not a feed — /admin/changelog's
reasoning. A bare list route that did take a window would owe a
total, which is what test_a_list_route_says_how_many is for; this
one takes none, so the count is here for the heading rather than to
say whether the answer was cut.
103class InstitutionSuggestion(CtfyModel): 104 """A domain this deployment's people hold proved addresses at. 105 106 ⚠️ **Raw, not registrable.** Where the registrable boundary falls is 107 exactly what nothing here can compute without a Public Suffix List, 108 so ``mails.tsinghua.edu.cn`` and ``tsinghua.edu.cn`` arrive 109 separately and the operator names the suffix they mean — after which 110 the matcher covers both and neither is suggested again. 111 """ 112 113 domain: str = "" 114 #: How many accounts hold a proved address there. 115 holders: int = 0 116 117 @computed_field 118 @property 119 def warning(self) -> str: 120 """``"public_suffix"`` where naming this would be too wide. 121 122 The same derivation `InstitutionInfo.warning` publishes, read 123 here *before* the operator commits rather than after — which is 124 the moment it can still change the answer. One producer 125 (`domain_warning`); two models compute it because each holds its 126 own domain, and a shared base would make the *suggestion* carry 127 a stored row's fields. 128 """ 129 return domain_warning(self.domain)
A domain this deployment's people hold proved addresses at.
⚠️ Raw, not registrable. Where the registrable boundary falls is
exactly what nothing here can compute without a Public Suffix List,
so mails.tsinghua.edu.cn and tsinghua.edu.cn arrive
separately and the operator names the suffix they mean — after which
the matcher covers both and neither is suggested again.
117 @computed_field 118 @property 119 def warning(self) -> str: 120 """``"public_suffix"`` where naming this would be too wide. 121 122 The same derivation `InstitutionInfo.warning` publishes, read 123 here *before* the operator commits rather than after — which is 124 the moment it can still change the answer. One producer 125 (`domain_warning`); two models compute it because each holds its 126 own domain, and a shared base would make the *suggestion* carry 127 a stored row's fields. 128 """ 129 return domain_warning(self.domain)
"public_suffix" where naming this would be too wide.
The same derivation InstitutionInfo.warning publishes, read
here before the operator commits rather than after — which is
the moment it can still change the answer. One producer
(domain_warning); two models compute it because each holds its
own domain, and a shared base would make the suggestion carry
a stored row's fields.
132class InstitutionSuggestionPage(CtfyModel): 133 """A window onto the suggestions, with how many there were. 134 135 ⚠️ **The total is not decoration**: this route takes a ``limit``, so 136 a bare list could not tell a complete answer from a truncated one — 137 and "20 of 137 domains nobody has named" is the sentence that tells 138 an operator whether they are nearly done. 139 """ 140 141 items: list[InstitutionSuggestion] = Field(default_factory=list) 142 total: int = 0
A window onto the suggestions, with how many there were.
⚠️ The total is not decoration: this route takes a limit, so
a bare list could not tell a complete answer from a truncated one —
and "20 of 137 domains nobody has named" is the sentence that tells
an operator whether they are nearly done.
28class LeaderboardCell(CtfyModel): 29 """One model's solve stats within one dimension bucket (e.g. difficulty=hard).""" 30 31 key: str = "" 32 attempted: int = 0 33 solved: int = 0 34 solve_rate: float = 0.0
One model's solve stats within one dimension bucket (e.g. difficulty=hard).
620class LinkStartResponse(CtfyModel): 621 """Returned by ``POST /auth/identities/link/{provider}``. 622 623 The caller is already authenticated via XHR, so we don't 302 them; 624 instead we hand back the authorize URL for the frontend to navigate 625 the browser to. 626 """ 627 628 authorize_url: str
Returned by POST /auth/identities/link/{provider}.
The caller is already authenticated via XHR, so we don't 302 them; instead we hand back the authorize URL for the frontend to navigate the browser to.
77class LinkedIdentity(CtfyModel): 78 id: str 79 # "github" | "google" (OAuth) or "password" for a local credential. 80 provider: str 81 provider_email: str = "" 82 provider_display_name: str = "" 83 # Stable third-party account id (GitHub numeric id, Google `sub`); 84 # empty for the local password credential. 85 provider_user_id: str = "" 86 # GitHub `@handle`; empty for Google and password. 87 provider_login: str = "" 88 avatar_url: str = "" 89 created_at: datetime | None = None 90 last_used_at: datetime | None = None
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
840class LlmBudgetPage(CtfyModel): 841 """The organiser's LLM-spend roster. 842 843 ``gateway_configured`` is on the envelope for the reason 844 ``webhook_configured`` is on the email-suppression list: an empty 845 table otherwise reads as "nobody has spent anything" when it means 846 "this deployment enforces no budgets at all", and those call for 847 opposite actions. 848 """ 849 850 items: list[LlmBudgetRow] 851 total: int 852 gateway_configured: bool
The organiser's LLM-spend roster.
gateway_configured is on the envelope for the reason
webhook_configured is on the email-suppression list: an empty
table otherwise reads as "nobody has spent anything" when it means
"this deployment enforces no budgets at all", and those call for
opposite actions.
565class LlmBudgetResetInfo(CtfyModel): 566 """Response from the LLM-budget reset endpoint. 567 568 ⚠️ ``outcome`` is the point of the model. A reset touches two stores 569 and can legitimately land as *nothing to reset*; reporting only 570 ``200`` would make "the team has a fresh allowance" and "the gateway 571 is unreachable so nothing changed" the same reply, which is exactly 572 the failure the seam is written to avoid. 573 """ 574 575 team_id: str 576 competition_id: str 577 challenge_id: str 578 outcome: str 579 reason: str
Response from the LLM-budget reset endpoint.
⚠️ outcome is the point of the model. A reset touches two stores
and can legitimately land as nothing to reset; reporting only
200 would make "the team has a fresh allowance" and "the gateway
is unreachable so nothing changed" the same reply, which is exactly
the failure the seam is written to avoid.
555class LlmBudgetResetRequest(CtfyModel): 556 """Request body for ``POST /admin/teams/.../llm-budget/.../reset``. 557 558 ``reason`` is free-form audit metadata, capped like its 559 question-attempt twin so it round-trips through the SSE frame. 560 """ 561 562 reason: str = Field(default="", max_length=500)
Request body for POST /admin/teams/.../llm-budget/.../reset.
reason is free-form audit metadata, capped like its
question-attempt twin so it round-trips through the SSE frame.
797class LlmBudgetRow(CtfyModel): 798 """One team's LLM spend on one challenge. 799 800 ⚠️ ``budget_tokens`` is the cap **as configured right now**, not the 801 cap that applied when the spend happened: it is one deployment-wide 802 setting the operator can retune mid-event. So a row can read 803 ``spent 90k of 200k`` for a team that really was refused at 50k, and 804 the honest place to see what happened *then* is the 805 ``llm_quota_exhausted`` activity row, which carries the cap it 806 crossed. Reporting a per-row historical cap would mean storing one, 807 which is a column that exists only to be believed. 808 809 ``exhausted`` is derived from the pair rather than stored, for the 810 same reason: it is a statement about the two numbers on this row and 811 must not disagree with them. 812 """ 813 814 team_id: str 815 team_name: str = "" 816 competition_id: str 817 challenge_id: str 818 spent_tokens: int 819 budget_tokens: int 820 #: Whether this team still holds a live grant for this challenge. 821 #: A budget outlives the box that spent it — the key is 822 #: ``(team, competition, challenge)`` precisely so a fresh instance 823 #: does not refill — so a row with no live grant is the normal 824 #: shape after a box stops, not an orphan. 825 live: bool = False 826 827 @computed_field 828 @property 829 def exhausted(self) -> bool: 830 """⚠️ A **`computed_field`**, unlike ``TeamState.is_disqualified`` 831 which is deliberately a plain ``@property``. Opposite reasons, 832 and the distinction is the model's kind: that one is *state*, so 833 ``model_dump`` feeds ``to_orm`` and every dumped key has to be a 834 column; this is a *wire* model whose whole purpose is to reach a 835 client, and a plain property simply never leaves the process. 836 """ 837 return self.budget_tokens > 0 and self.spent_tokens >= self.budget_tokens
One team's LLM spend on one challenge.
⚠️ budget_tokens is the cap as configured right now, not the
cap that applied when the spend happened: it is one deployment-wide
setting the operator can retune mid-event. So a row can read
spent 90k of 200k for a team that really was refused at 50k, and
the honest place to see what happened then is the
llm_quota_exhausted activity row, which carries the cap it
crossed. Reporting a per-row historical cap would mean storing one,
which is a column that exists only to be believed.
exhausted is derived from the pair rather than stored, for the
same reason: it is a statement about the two numbers on this row and
must not disagree with them.
827 @computed_field 828 @property 829 def exhausted(self) -> bool: 830 """⚠️ A **`computed_field`**, unlike ``TeamState.is_disqualified`` 831 which is deliberately a plain ``@property``. Opposite reasons, 832 and the distinction is the model's kind: that one is *state*, so 833 ``model_dump`` feeds ``to_orm`` and every dumped key has to be a 834 column; this is a *wire* model whose whole purpose is to reach a 835 client, and a plain property simply never leaves the process. 836 """ 837 return self.budget_tokens > 0 and self.spent_tokens >= self.budget_tokens
⚠️ A computed_field, unlike TeamState.is_disqualified
which is deliberately a plain @property. Opposite reasons,
and the distinction is the model's kind: that one is state, so
model_dump feeds to_orm and every dumped key has to be a
column; this is a wire model whose whole purpose is to reach a
client, and a plain property simply never leaves the process.
1211class LlmCallBody(CtfyModel): 1212 """One call's archived conversation. 1213 1214 ⚠️ **Attacker-authored text.** Every byte of the prompt half is 1215 written by whoever is attacking the challenge. Rendered as plain 1216 text, never markdown, never HTML. 1217 """ 1218 1219 id: str = "" 1220 #: The rewritten request the gateway actually sent, as JSON text. 1221 request: str = "" 1222 #: The completion, reassembled from the stream where it was one. 1223 response: str = "" 1224 truncated: bool = False 1225 #: ``False`` when retention has reclaimed the object; the row stays 1226 #: so the page can say the call happened. 1227 available: bool = True
One call's archived conversation.
⚠️ Attacker-authored text. Every byte of the prompt half is written by whoever is attacking the challenge. Rendered as plain text, never markdown, never HTML.
798class LlmCallPage(CtfyModel): 799 """A page of calls, plus what the reader needs to trust it.""" 800 801 items: list[LlmCallRow] = Field(default_factory=list) 802 total: int = 0 803 #: ⚠️ Distinguishes "no calls" from "this deployment keeps no index", 804 #: which look identical and call for opposite actions. Same argument 805 #: as ``LlmBudgetPage.gateway_configured``. 806 indexing: bool = True 807 #: When the built-in price list was true. A money figure with no date 808 #: is the one somebody trusts a year later. 809 rates_as_of: str = ""
A page of calls, plus what the reader needs to trust it.
698class LlmCallRow(CtfyModel): 699 """One relayed model call, as a page reads it. 700 701 Wire naming: ``id`` is this row's own id, everything ``<entity>_id`` 702 is a foreign key. The frontend reads these verbatim. 703 704 ⚠️ ``usage_known`` rides along so the page can tell "0 tokens" from 705 "nobody knows" — a distinction ``extract_usage`` preserves on purpose 706 (the torn-stream rule charges rather than refunds) and which a table 707 rendering both as ``0`` would destroy at the last metre. 708 709 ⚠️ ``estimated_usd`` is ``None`` for a model no price list knows, and 710 the page must show a dash rather than ``$0.00``. It is a 711 ``computed_field`` because this is a *wire* model — see the 712 ``LlmBudgetRow.exhausted`` rule. 713 """ 714 715 id: str = "" 716 at_ts: float = 0.0 717 # ⚠️ ``str``, not ``Principal`` — the wire projection of a column 718 # that can hold a value this platform does not model (see the note 719 # on ``LlmCallState.principal``). Narrowing here moves the failure 720 # from "the drawer shows an unfamiliar word" to "the admin page 721 # 500s", which is the worse of the two by a distance. 722 principal: str = "instance" 723 competition_id: str = "" 724 team_id: str = "" 725 challenge_id: str = "" 726 instance_id: str = "" 727 flavor: FlavorOrUnset = "" 728 model: str = "" 729 status: int = 0 730 stream: bool = False 731 aborted: bool = False 732 truncated: bool = False 733 stop_reason: str = "" 734 usage_known: bool = False 735 input_tokens: int = 0 736 output_tokens: int = 0 737 cache_write_tokens: int = 0 738 cache_read_tokens: int = 0 739 upstream_ms: int = 0 740 queue_ms: int = 0 741 attempts: int = 1 742 key_id: str = "" 743 request_bytes: int = 0 744 #: Whether the body is still in storage. The *key* itself never 745 #: reaches a player — it is an address in the operator's bucket. 746 has_body: bool = False 747 #: What the owning squad is called, beside the ``team_id`` above. 748 #: ``""`` on the player's own run page, where the owner would only 749 #: ever be themselves. 750 #: 751 #: ⚠️ **This was ``owner_label``, and it was fed ``row.team_id`` 752 #: verbatim for its whole life** — a uuid in the field two admin 753 #: pages render as their Owner column, which drew 754 #: ``3f9a1b2c-…`` under a heading promising a name. 755 #: `MyEvalRunRow.owner_label`'s recorded defect on the neighbouring 756 #: model, one composer along. 757 #: 758 #: ⚠️ **``team_name``, not ``owner_display_name``, and the earlier 759 #: refusal got half of its own reason wrong.** It argued the suffix 760 #: "would enlist a squad's identity in a person-identity scan" — 761 #: measured, ``test_anonymity_doors.IDENTITY_FIELDS`` has carried 762 #: ``team_name`` and ``first_blood_team_name`` since it was written, 763 #: so squad identity was never outside that scan and being in it is 764 #: the point: it is what forces the door question to be answered. 765 #: What survives of the refusal is the *producer* half — 766 #: `entity_labels` rules on what a **person** is called and 767 #: `LlmCallState` carries no user id — so the label comes from 768 #: `ctfy.server.team_labels.operator_team_labels` and the field 769 #: takes the name `InstanceInfo` already uses for this exact fact. 770 #: 771 #: ⚠️ **Never the id as a fallback** (`InstanceInfo.team_name`'s 772 #: contract). ``""`` beside a set id is the third state, and here it 773 #: has two readings the id itself separates: a uuid means the squad 774 #: is gone or never named itself, while ``__platform__`` is 775 #: `PLATFORM_TEAM_ID`, the sentinel chosen to read as itself. 776 team_name: str = "" 777 778 @computed_field 779 @property 780 def estimated_usd(self) -> float | None: 781 """⚠️ ``None``, never ``0.0``, for an unpriced model.""" 782 from ctfy.core.llm_pricing import estimate_usd 783 from ctfy.core.llm_spend import Spend 784 785 if not self.usage_known: 786 return None 787 return estimate_usd( 788 self.model, 789 Spend( 790 input_tokens=self.input_tokens, 791 output_tokens=self.output_tokens, 792 cache_write_tokens=self.cache_write_tokens, 793 cache_read_tokens=self.cache_read_tokens, 794 ), 795 )
One relayed model call, as a page reads it.
Wire naming: id is this row's own id, everything <entity>_id
is a foreign key. The frontend reads these verbatim.
⚠️ usage_known rides along so the page can tell "0 tokens" from
"nobody knows" — a distinction extract_usage preserves on purpose
(the torn-stream rule charges rather than refunds) and which a table
rendering both as 0 would destroy at the last metre.
⚠️ estimated_usd is None for a model no price list knows, and
the page must show a dash rather than $0.00. It is a
computed_field because this is a wire model — see the
LlmBudgetRow.exhausted rule.
778 @computed_field 779 @property 780 def estimated_usd(self) -> float | None: 781 """⚠️ ``None``, never ``0.0``, for an unpriced model.""" 782 from ctfy.core.llm_pricing import estimate_usd 783 from ctfy.core.llm_spend import Spend 784 785 if not self.usage_known: 786 return None 787 return estimate_usd( 788 self.model, 789 Spend( 790 input_tokens=self.input_tokens, 791 output_tokens=self.output_tokens, 792 cache_write_tokens=self.cache_write_tokens, 793 cache_read_tokens=self.cache_read_tokens, 794 ), 795 )
⚠️ None, never 0.0, for an unpriced model.
397class LlmModelOption(CtfyModel): 398 """A model an entrant may pick, as an entrant may see it. 399 400 ⚠️ **A projection, not :class:`LlmModelState`.** The registry row 401 carries the operator's own scratchpad, who reviewed it, and the 402 addressing that says where the deployment's credential is spent 403 (``base_url``, ``provider_model``). None of that is an entrant's 404 business — and a route returning the state model would publish every 405 one of those the first time somebody added a field to it. 406 407 ⚠️ ``flavor`` is load-bearing rather than descriptive: a 408 bring-your-own key is stored per flavor, so "do I already have a key 409 for this model" is a question the page can only answer if the model 410 says which flavor it is. 411 """ 412 413 id: str = "" 414 display_name: str = "" 415 flavor: FlavorOrUnset = "" 416 vendor: str = "" 417 #: USD per 1,000,000 tokens. Carried so an entrant can weigh a 418 #: choice against the allowance they hold; ``0`` means unpriced, the 419 #: same reading the leaderboard gives it. 420 input_price_per_mtok: float = 0.0 421 output_price_per_mtok: float = 0.0
A model an entrant may pick, as an entrant may see it.
⚠️ A projection, not LlmModelState. The registry row
carries the operator's own scratchpad, who reviewed it, and the
addressing that says where the deployment's credential is spent
(base_url, provider_model). None of that is an entrant's
business — and a route returning the state model would publish every
one of those the first time somebody added a field to it.
⚠️ flavor is load-bearing rather than descriptive: a
bring-your-own key is stored per flavor, so "do I already have a key
for this model" is a question the page can only answer if the model
says which flavor it is.
550class LlmProviderChoice(CtfyModel): 551 """One upstream a user may point their own key at. 552 553 ⚠️ **Served, not shipped in the client.** The operator can replace 554 the set, and a client with its own copy would offer providers the 555 server refuses — the user then does exactly what the page said and 556 is turned away. 557 558 ⚠️ **``flavor`` rides with the address** because a provider is both. 559 DeepSeek, Moonshot and OpenAI all speak ``openai``, so a form asking 560 the two separately contains a wrong combination, and the wrong 561 combination stores a key where nothing will look for it. 562 """ 563 564 id: str 565 label: str 566 #: ⚠️ Required and never empty: an entry in this catalog *is* a 567 #: provider, so a dialect-less one would be an address the form 568 #: could offer and no credential could be stored against. 569 flavor: Flavor 570 base_url: str
One upstream a user may point their own key at.
⚠️ Served, not shipped in the client. The operator can replace the set, and a client with its own copy would offer providers the server refuses — the user then does exactly what the page said and is turned away.
⚠️ flavor rides with the address because a provider is both.
DeepSeek, Moonshot and OpenAI all speak openai, so a form asking
the two separately contains a wrong combination, and the wrong
combination stores a key where nothing will look for it.
774class LlmProviderCreate(CtfyModel): 775 """A provider as an operator types it. 776 777 ⚠️ Validated **here**, not only at the gateway. ``POST /keys/sync`` 778 reports a ``rejected`` list whose whole meaning is "the platform and 779 the gateway have drifted, somebody look" — which is only true if a 780 row the gateway would refuse could not have been stored. Without 781 this, a refusal there would be the ordinary result of a typo and 782 nobody would read it. 783 """ 784 785 key_id: str = Field(min_length=1, max_length=64) 786 api_key: str = Field(min_length=1, max_length=512) 787 #: ⚠️ **The type is the check.** This was a ``str`` with a 788 #: ``field_validator`` reaching into the gateway package for a tuple 789 #: of two literals — one of three hand-rolled copies of a set the 790 #: enum already closes. Declaring it here refuses the same typo at 791 #: the same boundary with the same 422, and puts the allowed values 792 #: in the OpenAPI schema, so the browser's own types carry them too. 793 flavor: Flavor 794 base_url: str = ""
A provider as an operator types it.
⚠️ Validated here, not only at the gateway. POST /keys/sync
reports a rejected list whose whole meaning is "the platform and
the gateway have drifted, somebody look" — which is only true if a
row the gateway would refuse could not have been stored. Without
this, a refusal there would be the ordinary result of a typo and
nobody would read it.
582class LlmProviderInfo(CtfyModel): 583 """One upstream credential as an operator may see it. 584 585 ⚠️ **No ``api_key`` field, and that absence is the model.** The 586 column is sealed precisely so the secret cannot be read back, and a 587 wire model carrying it would undo that for every caller at once. A 588 last-four ``hint`` is included because an operator rotating one of 589 several keys has to tell them apart, and four characters of a 590 100-bit secret identify without reconstructing — the alternative is 591 a table of opaque ids where picking the wrong row revokes the wrong 592 vendor. 593 """ 594 595 key_id: str 596 flavor: FlavorOrUnset 597 base_url: str = "" 598 #: Last four characters of the secret, or empty for one too short to 599 #: hint at without giving away a meaningful share of it. 600 hint: str = "" 601 added_by: str = "" 602 added_at: str = "" 603 #: Whether the platform's last key push carried this row to the 604 #: gateway: ``synced`` / ``pending`` / ``rejected``, or ``""`` when 605 #: there is no gateway to push to. 606 #: 607 #: ⚠️ **A record of the last push, never a live reading**, and the 608 #: page's copy has to say so. The gateway persists nothing, so it can 609 #: restart and lose the whole fleet a second after acknowledging it; 610 #: the platform hears on the next tick. Deriving it any other way 611 #: would mean asking the gateway from a browser — a second source for 612 #: a fact only the pushing party knows the outcome of. 613 #: 614 #: ⚠️ Defaults to ``""``, the *not-a-new-claim* direction. Defaulting 615 #: to ``synced`` would make a producer that forgets assert every 616 #: credential is live; same rule as ``ScoreboardEntry.review_status`` 617 #: and ``SubmissionResponse.ranked``. 618 sync_state: str = "" 619 #: Whether this secret is also an ``CTFY_EVAL_*`` key — one upstream 620 #: account serving both the players' gateway and the eval harness, 621 #: which spends **directly**. The gateway's ledger then calibrates 622 #: against capacity somebody else is consuming: it under-counts, the 623 #: limiter over-admits, and the first observable is a 429 storm or a 624 #: bill. 625 #: 626 #: ⚠️ Derived on the read, never stored — a column would need 627 #: clearing when either side is rotated, and a missed write reads 628 #: exactly like "these are different accounts". Defaults ``False`` 629 #: for the usual reason: the default must not be a new claim. 630 shares_eval_account: bool = False 631 #: How many *user* keys sit at this row's ``(flavor, base_url)`` and 632 #: therefore spend instead of it. 633 #: 634 #: ⚠️ **Not a warning about the credential — a warning about the 635 #: page.** `execute_one_eval_run` reads 636 #: ``get_user_llm_key(owner, flavor, model.base_url)`` first and 637 #: never consults the fleet row when one answers, so for those runs 638 #: this row's green probe is about a credential nothing spent. 639 #: Measured: an operator read that tick while a run burned 77 640 #: minutes against an expired BYOK key at the same address, and 641 #: nothing on either page connected the two. 642 #: 643 #: ⚠️ A count, never the owners. Naming them would make this page 644 #: report who holds a credential with which provider — the same 645 #: disclosure `DELETE /eval/llm-keys/{flavor}`'s 404 exists to 646 #: avoid. The number is what changes the reading; the identities 647 #: are somebody else's business. 648 #: 649 #: ⚠️ Derived on the read, like ``shares_eval_account`` above and 650 #: for its reason: a stored count needs clearing whenever any user 651 #: adds or drops a key, and a missed write reads exactly like 652 #: "nothing shadows this". 653 shadowed_by: int = 0
One upstream credential as an operator may see it.
⚠️ No api_key field, and that absence is the model. The
column is sealed precisely so the secret cannot be read back, and a
wire model carrying it would undo that for every caller at once. A
last-four hint is included because an operator rotating one of
several keys has to tell them apart, and four characters of a
100-bit secret identify without reconstructing — the alternative is
a table of opaque ids where picking the wrong row revokes the wrong
vendor.
699class LlmProviderPage(CtfyModel): 700 items: list[LlmProviderInfo] = Field(default_factory=list) 701 total: int = 0 702 #: Whether this deployment has a gateway at all. ⚠️ An empty table 703 #: is ambiguous and must say which emptiness it is: "no providers 704 #: configured" and "this platform pushes to nothing" render 705 #: identically and call for opposite actions. Same argument as 706 #: ``webhook_configured`` on the email-suppression list. 707 gateway_configured: bool = False 708 #: Whether an upstream key can be sealed at rest here at all, i.e. 709 #: whether ``POST`` can succeed. 710 #: 711 #: ⚠️ **A precondition, and the browser could not see it.** The write 712 #: route pre-checks ``pii.is_enabled()`` and answers ``503 713 #: sealing_not_configured`` with a message naming the variable to 714 #: set — but nothing on the read said so, so the only way to find out 715 #: was to paste a *real* credential into a form that was always going 716 #: to refuse it. The wizard asks before it opens. 717 #: 718 #: ⚠️ Defaults ``False`` because a default must never be a new claim 719 #: (same rule as ``gateway_configured`` above and 720 #: ``SubmissionResponse.ranked``) — and the *client* reads 721 #: ``=== false`` rather than ``!``, so an older server, which sends 722 #: no field at all, does not have its add button taken away. Gates 723 #: fail open; only a server that positively says "I cannot seal" 724 #: closes this one. 725 sealing_configured: bool = False
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
728class LlmProviderProbe(CtfyModel): 729 """What one probe of one credential established. 730 731 ⚠️ **A code, never a boolean.** Four refusals mean four different 732 fields to go and fix — the key, the address, the model, the account — 733 and one red cross reports them identically. The page renders the code 734 and shows ``detail`` beside it, which is the only thing separating 735 the two ambiguous causes of ``bad_request``. 736 """ 737 738 key_id: str = "" 739 ok: bool = False 740 #: ``ok`` · ``auth_failed`` · ``quota_exceeded`` · ``unreachable`` · 741 #: ``bad_request`` — the provider's verdict; plus ``no_gateway`` · 742 #: ``gateway_unreachable`` · ``not_synced``, which are facts about 743 #: *this platform's* wiring rather than about the credential. 744 code: str = "" 745 #: The status the provider actually sent, kept verbatim: the code is 746 #: a summary and a bug report wants the original. 0 when nothing 747 #: answered. 748 status: int = 0 749 #: The provider's own message, bounded. Never our own paraphrase — 750 #: it is what names the wrong model or the wrong host. 751 detail: str = "" 752 #: What the model actually said, bounded by the gateway. 753 #: 754 #: ⚠️ **This is what makes the verdict about the model rather than 755 #: about the endpoint.** A 200 with an empty completion — a misrouted 756 #: gateway, a content filter, a proxy answering for a provider it 757 #: never reached — is indistinguishable from a working credential by 758 #: status code alone. Showing the answer is how an operator tells 759 #: those apart, and empty is therefore a *result* rather than a 760 #: missing field. 761 #: 762 #: ⚠️ **Reported, never graded.** A check on the text would fail on 763 #: correct configuration — a weak model, a stray "Sure!", a reply in 764 #: another language — and this repository's own record is that such a 765 #: check is the one that gets deleted. 766 reply: str = "" 767 #: ⚠️ The offline stub answers **ok**, so a green tick there would be 768 #: the dangerous result: an operator carries it into an event whose 769 #: every answer is invented. Defaults ``False`` for the usual reason 770 #: — the default must not be a new claim. 771 stub: bool = False
What one probe of one credential established.
⚠️ A code, never a boolean. Four refusals mean four different
fields to go and fix — the key, the address, the model, the account —
and one red cross reports them identically. The page renders the code
and shows detail beside it, which is the only thing separating
the two ambiguous causes of bad_request.
110class LoginRequest(CtfyModel): 111 email: EmailStr 112 password: str = Field(min_length=1, max_length=256)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
65class MeResponse(UserInfo): 66 """``GET /me`` payload — the logged-in user's full profile. 67 68 Extends :class:`UserInfo` with auth-state context so the frontend 69 can render Settings (linked providers, admin badge) with a single 70 request. ``providers`` lists provider names currently bound 71 (e.g. ``["github", "google"]``); ``token_kind`` distinguishes 72 ``"user"`` (browser session) from ``"fine_grained"`` (CLI / agent 73 token). 74 75 ``is_admin`` is the OR of admin + super_admin so existing clients 76 keep working without inspecting ``role`` directly. 77 78 ``competition_teams`` carries every per-comp team the user is 79 currently on. The legacy ``current_team_id`` / 80 ``current_team_name`` fields were dropped — there is no global 81 "current team" any more. 82 """ 83 84 email: str = "" 85 is_admin: bool = False 86 role: UserRole = "user" 87 providers: list[str] = Field(default_factory=list) 88 #: When this account was taken off public standings; ``None`` = it 89 #: counts. On *this* projection because it is the account's own view 90 #: of itself: being invisible to the field without being told is the 91 #: kind of surprise that gets reported as a scoring bug. It is not on 92 #: the public ``UserInfo`` — a player has no business learning which 93 #: accounts belong to the deployment. 94 standings_excluded_at: datetime | None = None 95 token_kind: TokenKind = "user" 96 #: Which of this account's addresses the badge derives from; ``""`` 97 #: is no badge. On *this* projection and never on 98 #: :class:`UserInfo`, for `standings_excluded_at`'s reason inverted: 99 #: the badge itself is public and the address behind it is not, so 100 #: the one reader entitled to it is the account choosing. 101 #: 102 #: ⚠️ It can name an address that earns **no** badge — unverified, 103 #: or at a domain the operator has not named — and the settings card 104 #: needs exactly that pair to say *"chosen, and nothing is being 105 #: shown"*. Reading ``affiliation`` alone cannot tell that from 106 #: *"nothing chosen"*, which are opposite repairs. 107 affiliation_email: str = "" 108 profile_visibility: dict[str, bool] = Field(default_factory=dict) 109 competition_teams: list[CompetitionMembershipInfo] = Field(default_factory=list) 110 # Competitions this user per-comp-administers. Empty for global 111 # admins/super_admins (already a superset — the frontend treats 112 # ``is_admin`` as "admin of every competition"). 113 competition_admin_ids: list[str] = Field(default_factory=list) 114 # Competitions whose registration roster this user may read + rule on. 115 # A superset of ``competition_admin_ids`` (an admin can review), plus 116 # any narrow ``reviewer`` grant. Drives whether the frontend shows the 117 # registrations link; the server re-checks on every request. 118 competition_reviewer_ids: list[str] = Field(default_factory=list) 119 # Competitions whose uploaded challenges this user may read and rule 120 # on (plan P2.3). A superset of ``competition_admin_ids``, plus any 121 # narrow authoring-reviewer grant — the same shape as the list above, 122 # and deliberately **not** a subset of it: this grant reaches no 123 # entrant's registration details. Drives whether the frontend shows 124 # the upload queue; the server re-checks on every request. 125 competition_authoring_reviewer_ids: list[str] = Field(default_factory=list) 126 # True when this request asked to be served as an ordinary player 127 # (``X-Ctfy-View-As: player``). Transient — it describes the 128 # *request*, never the stored account, so ``role`` above already 129 # reads ``user`` while the DB row still says admin. The client 130 # needs it to render the "you are previewing" banner and the way 131 # back out; without it a narrowed session is indistinguishable 132 # from an ordinary one and nobody can leave. 133 viewing_as_player: bool = False 134 # What this account said it came here to do, asked once at sign-up. 135 # ⚠️ ``""`` is "we never asked", never a preference — the welcome 136 # wizard shows *because* of it, and an older server that omits the 137 # field lands on the same default, which is the honest reading. 138 onboarding_intent: OnboardingIntent = "" 139 # Whether this account's address has ever been proved — by an OAuth 140 # provider, an emailed sign-in code, or the verification exchange. 141 # 142 # ⚠️ Defaults **False**, the honest reading: the platform holds no 143 # proof until something writes one, so the withheld value asserts 144 # nothing new. The client must not turn that into a nag on its own — 145 # it reads ``=== false`` **and** the flag below, so an older server 146 # (which sends neither) shows nothing. 147 email_verified: bool = False 148 # Whether this deployment refuses writes from an unproved address. 149 # Composed server-side from the platform setting *and* whether mail 150 # can be sent at all: enforcement without a mail provider is an 151 # outage, not a policy, so the server resolves it rather than letting 152 # the browser ``&&`` two facts and reach a different answer than the 153 # gate did. Defaults **False** — a client that cannot tell must never 154 # invent a requirement. 155 email_verification_required: bool = False 156 # Which factor this account re-proves before ``DELETE /me`` runs — 157 # see :mod:`ctfy.server.account_deletion`. Composed server-side for 158 # the reason the field above is: the gate's own predicate, so the 159 # Danger Zone cannot render a control the route will refuse, or skip 160 # one it demands. Defaults ``none``, the behaviour that shipped 161 # before the gate existed. 162 deletion_proof: AccountDeletionProof = "none" 163 # When this account's challenge uploads reopen, while a pause after 164 # repeated rejections runs (plan P2.7); ``None`` when nothing is 165 # paused. Composed server-side from the upload route's own function 166 # (``ctfy.server.authoring_cooldown.paused_until``), so the authoring 167 # page cannot draw a form the route refuses. ⚠️ Defaults ``None`` — 168 # an older server that omits it must never read as a pause. 169 authoring_paused_until: datetime | None = None
GET /me payload — the logged-in user's full profile.
Extends UserInfo with auth-state context so the frontend
can render Settings (linked providers, admin badge) with a single
request. providers lists provider names currently bound
(e.g. ["github", "google"]); token_kind distinguishes
"user" (browser session) from "fine_grained" (CLI / agent
token).
is_admin is the OR of admin + super_admin so existing clients
keep working without inspecting role directly.
competition_teams carries every per-comp team the user is
currently on. The legacy current_team_id /
current_team_name fields were dropped — there is no global
"current team" any more.
82class MetaAgents(CtfyModel): 83 """How much of this deployment's population is machine, not human. 84 85 Admin-only, like the cluster counters it sits beside — an entrant 86 count is competitive telemetry. 87 88 ⚠️ **Three numbers, because they are three different questions and 89 only the first is "how many AI agents did users register".** A 90 harness is the registered image; an *entrant* is one synthetic user 91 per (owner, harness, model), so three agents across four models is 92 twelve entrants; a *run* is one dispatch. Collapsing them into a 93 single "agents" figure picks one meaning and makes the other two 94 unreadable — and the one a reader assumes is the one this field set 95 exists to name. 96 """ 97 98 #: Harnesses with a real owner — the answer to "how many agents did 99 #: our users register". ⚠️ Deliberately excludes the platform's own 100 #: rows, which carry ``owner_user_id == ""``; that empty string is a 101 #: value on the column (it is what ``is_dispatchable`` reads as 102 #: "built here, no review needed"), not an absent owner. 103 registered: int = 0 104 #: Of those, still awaiting review. The actionable one: an 105 #: unreviewed harness cannot be dispatched, so a number sitting here 106 #: is somebody blocked on an operator. 107 pending_review: int = 0 108 #: The platform's own harnesses — the complement of ``registered``, 109 #: carried so a reader can tell "nobody has registered one yet" from 110 #: "the registry is empty". 111 platform: int = 0 112 #: Synthetic agent users (``UserState.entrant_kind == "agent"``). 113 entrants: int = 0 114 #: Active registered models an agent can be pointed at. 115 models: int = 0 116 #: Evaluation runs in flight — the agent twin of 117 #: ``running_instances``. 118 runs_running: int = 0
How much of this deployment's population is machine, not human.
Admin-only, like the cluster counters it sits beside — an entrant count is competitive telemetry.
⚠️ Three numbers, because they are three different questions and only the first is "how many AI agents did users register". A harness is the registered image; an entrant is one synthetic user per (owner, harness, model), so three agents across four models is twelve entrants; a run is one dispatch. Collapsing them into a single "agents" figure picks one meaning and makes the other two unreadable — and the one a reader assumes is the one this field set exists to name.
280class MetaAuthoredArchive(CtfyModel): 281 """What an uploaded challenge archive may be, in bytes. 282 283 ⚠️ **Published so the upload form can say it, and it could not.** 284 An archive over the limit came back as *too large to upload* with no 285 number anywhere on the page — the server's own message carried one, 286 and the page shows the error code's copy instead — so an author who 287 was refused learned nothing they could act on. The form reads these 288 to state the limits beside the file picker and to refuse an 289 oversized file before sending it. 290 291 ⚠️ **Zero is "this server did not say"**, which is what an older 292 platform sends: a client then states no limit and refuses nothing 293 itself, leaving the verdict to the server — never a guessed number 294 that could refuse an upload the server would take. 295 """ 296 297 #: ``max_authored_challenge_bytes`` — the archive as sent, compressed. 298 archive_bytes: int = 0 299 #: ``MAX_TOTAL_BYTES`` — every file in it, unpacked. 300 unpacked_bytes: int = 0 301 #: ``MAX_FILE_BYTES`` — any one file, unpacked. 302 file_bytes: int = 0
What an uploaded challenge archive may be, in bytes.
⚠️ Published so the upload form can say it, and it could not. An archive over the limit came back as too large to upload with no number anywhere on the page — the server's own message carried one, and the page shows the error code's copy instead — so an author who was refused learned nothing they could act on. The form reads these to state the limits beside the file picker and to refuse an oversized file before sending it.
⚠️ Zero is "this server did not say", which is what an older platform sends: a client then states no limit and refuses nothing itself, leaving the verdict to the server — never a guessed number that could refuse an upload the server would take.
58class MetaChallenges(CtfyModel): 59 """Source-control identity of the challenges repository, plus the 60 public count of available challenges. 61 62 ``commit_sha`` is None when the challenges directory isn't a git 63 working tree (e.g. ``CTFY_CHALLENGES_REPO=""`` or a bare checkout). 64 ``total`` is visible to everyone — the list itself is browsable. 65 66 ``suites`` / ``environments`` are the other two axes of "how big is 67 the catalog" (see :class:`~ctfy.core.challenge.CatalogCounts`): 68 distinct source buckets, and the subset that ships a runnable 69 Docker topology rather than being a pure QA item. Both are derived 70 from the same public spec list, so they carry no more information 71 than ``GET /challenges`` already does. 72 """ 73 74 commit_sha: str | None = None 75 commit_url: str | None = None 76 repo_url: str = "" 77 total: int = 0 78 suites: int = 0 79 environments: int = 0
Source-control identity of the challenges repository, plus the public count of available challenges.
commit_sha is None when the challenges directory isn't a git
working tree (e.g. CTFY_CHALLENGES_REPO="" or a bare checkout).
total is visible to everyone — the list itself is browsable.
suites / environments are the other two axes of "how big is
the catalog" (see ~ctfy.core.challenge.CatalogCounts):
distinct source buckets, and the subset that ships a runnable
Docker topology rather than being a pure QA item. Both are derived
from the same public spec list, so they carry no more information
than GET /challenges already does.
254class MetaContribute(CtfyModel): 255 """Where the Contribute entry leads, as *this caller* may be shown it. 256 257 Composed by ``ctfy.server.contribute.contribute_for``: the operator's 258 ``contribute_competition_id``, kept only while that competition takes 259 challenges and only for a caller who may see it. 260 261 ⚠️ **``MetaResponse.contribute`` being ``None`` means "no entry", 262 and an older server that sends no field means the same.** A client 263 hides the sidebar item, the home card and the page's destination on 264 it — an entry that leads to a 404, or to "authoring has closed", is 265 worse than no entry. 266 267 ⚠️ **Per caller, never shared**, because visibility is: publishing 268 the id of a ``private_hidden`` competition to everybody is the 269 existence probe ``resolve_visible_competition`` answers 404 to 270 prevent. 271 """ 272 273 #: The competition the entry leads to. 274 competition_id: str 275 #: Its title, so the entry can say where it leads without a second 276 #: request. 277 title: str = ""
Where the Contribute entry leads, as this caller may be shown it.
Composed by ctfy.server.contribute.contribute_for: the operator's
contribute_competition_id, kept only while that competition takes
challenges and only for a caller who may see it.
⚠️ MetaResponse.contribute being None means "no entry",
and an older server that sends no field means the same. A client
hides the sidebar item, the home card and the page's destination on
it — an entry that leads to a 404, or to "authoring has closed", is
worse than no entry.
⚠️ Per caller, never shared, because visibility is: publishing
the id of a private_hidden competition to everybody is the
existence probe resolve_visible_competition answers 404 to
prevent.
176class MetaEvents(CtfyModel): 177 """How many competitions are open, as the *caller* may see them. 178 179 ⚠️ **Public, and that is a decision about ``access`` as well as 180 ``status``.** A published competition can still be 181 ``private_hidden``, which a non-invited user must not be able to 182 learn exists — the same probe ``competition_visible_to`` refuses by 183 answering 404 rather than 403. So this counts published rows that 184 are not hidden, which is exactly what the browsable catalog already 185 lists: no new disclosure, and a player finally gets the one number 186 that tells them there is something to play right now. 187 188 ⚠️ It under-reports for an invited user (their hidden event is not 189 in it). That is the safe direction and the catalog page shows them 190 the event anyway; the alternative is a per-caller invite join on a 191 chip. 192 """ 193 194 live: int = 0 195 upcoming: int = 0
How many competitions are open, as the caller may see them.
⚠️ Public, and that is a decision about access as well as
status. A published competition can still be
private_hidden, which a non-invited user must not be able to
learn exists — the same probe competition_visible_to refuses by
answering 404 rather than 403. So this counts published rows that
are not hidden, which is exactly what the browsable catalog already
lists: no new disclosure, and a player finally gets the one number
that tells them there is something to play right now.
⚠️ It under-reports for an invited user (their hidden event is not in it). That is the safe direction and the catalog page shows them the event anyway; the alternative is a per-caller invite join on a chip.
220class MetaInstanceTtl(CtfyModel): 221 """How long an instance may run, and what a client may offer for it. 222 223 ⚠️ **``options_s`` is composed by the server, and that is the whole 224 reason this group exists.** ``resolve_instance_ttl`` clamps a 225 caller's duration to ``max_instance_ttl_s`` *silently*, so a picker 226 that narrowed its own list would eventually offer a value the server 227 lowers without saying so — a control that lies about what pressing 228 it does. Nothing selectable from this list is clamped. 229 230 ⚠️ **Public, deliberately.** These are lifetimes, not capacity: a 231 player learns the default the instant their first countdown starts, 232 and a guest deciding whether to sign up is entitled to know a box 233 lives three hours rather than three minutes. 234 235 ⚠️ **An empty ``options_s`` is "this server did not say"**, which is 236 what an older platform sends, so a client reads it as *offer no 237 picker* rather than *offer nothing*. The single-press renewal still 238 works there: omitting ``ttl`` is what delegates to the platform. 239 """ 240 241 #: ``default_instance_ttl_s`` — what a caller who names no duration 242 #: gets, and the option a picker should land the keyboard on. 243 default_s: int = 0 244 #: ``max_instance_ttl_s``. On a **renewal** this bounds the 245 #: resulting deadline rather than the increment, so it is also the 246 #: most time a box can ever have ahead of it — see 247 #: :func:`ctfy.core.instance_ttl.plan_renewal`. 248 max_s: int = 0 249 #: Ascending, every entry in ``[1, max_s]``, always carrying both 250 #: ``default_s`` and ``max_s``. 251 options_s: list[int] = Field(default_factory=list)
How long an instance may run, and what a client may offer for it.
⚠️ options_s is composed by the server, and that is the whole
reason this group exists. resolve_instance_ttl clamps a
caller's duration to max_instance_ttl_s silently, so a picker
that narrowed its own list would eventually offer a value the server
lowers without saying so — a control that lies about what pressing
it does. Nothing selectable from this list is clamped.
⚠️ Public, deliberately. These are lifetimes, not capacity: a player learns the default the instant their first countdown starts, and a guest deciding whether to sign up is entitled to know a box lives three hours rather than three minutes.
⚠️ An empty options_s is "this server did not say", which is
what an older platform sends, so a client reads it as offer no
picker rather than offer nothing. The single-press renewal still
works there: omitting ttl is what delegates to the platform.
156class MetaLlm(CtfyModel): 157 """What the LLM gateway relayed today. Admin-only. 158 159 ⚠️ Counted from ``llm_calls`` — calls the platform itself relayed — 160 never from what an agent reported about itself. A run whose harness 161 left its rollup unset is invisible to the latter and fully counted 162 here. 163 164 ⚠️ Tokens, not dollars. Pricing a window means joining every row to 165 a model rate, and an unpriced model costs ``None`` rather than zero, 166 so a single figure here would silently under-report exactly the 167 models nobody has priced yet. The admin LLM pages own that sum. 168 """ 169 170 #: Since 00:00 UTC today. 171 calls_today: int = 0 172 input_tokens_today: int = 0 173 output_tokens_today: int = 0
What the LLM gateway relayed today. Admin-only.
⚠️ Counted from llm_calls — calls the platform itself relayed —
never from what an agent reported about itself. A run whose harness
left its rollup unset is invisible to the latter and fully counted
here.
⚠️ Tokens, not dollars. Pricing a window means joining every row to
a model rate, and an unpriced model costs None rather than zero,
so a single figure here would silently under-report exactly the
models nobody has priced yet. The admin LLM pages own that sum.
198class MetaMine(CtfyModel): 199 """What *this caller* has, as opposed to what the platform has. 200 201 Present for any signed-in user, admin or not — a status bar that 202 only ever spoke to operators left a player with a registered agent 203 no way to see it was still waiting for review. 204 205 ⚠️ **Only what nothing else already answers.** Solves, attempts and 206 the caller's running instances reach the bar through ``/me`` and 207 ``InstanceCountContext``; repeating them here would give one number 208 two sources, which is the shape that drifts. 209 """ 210 211 #: Harnesses this caller owns. 212 agents: int = 0 213 #: Of those, still awaiting an operator's review — the one number on 214 #: the bar that means *you* are blocked on somebody. 215 agents_pending: int = 0 216 #: This caller's own evaluation runs in flight. 217 runs_running: int = 0
What this caller has, as opposed to what the platform has.
Present for any signed-in user, admin or not — a status bar that only ever spoke to operators left a player with a registered agent no way to see it was still waiting for review.
⚠️ Only what nothing else already answers. Solves, attempts and
the caller's running instances reach the bar through /me and
InstanceCountContext; repeating them here would give one number
two sources, which is the shape that drifts.
121class MetaOps(CtfyModel): 122 """Operator-health counters: is anything wrong right now? 123 124 Admin-only. Every field here is a number an operator would want to 125 be non-zero-alerted on, which is why they sit together rather than 126 among the population counts. 127 """ 128 129 #: Competitions a player can be inside *right now* — published and 130 #: within a session window. ⚠️ Both halves, not just the clock: a 131 #: draft competition inside its own window is "running" by the dates 132 #: and invisible to every player, so a phase-only count reports 133 #: events nobody can enter as live ones. 134 #: 135 #: ⚠️ **Not the same number as ``MetaEvents.live``**, and the pair is 136 #: kept rather than reconciled: this one includes ``private_hidden`` 137 #: events, which the public count must not. An operator asking "is 138 #: anything live" wants the true answer including the invite-only 139 #: ones; showing them the public number instead would under-report 140 #: in the one direction that hides an event from the person 141 #: responsible for it. The panel shows both rows rather than a 142 #: difference, so neither figure has to be derived by a reader. 143 competitions_live: int = 0 144 competitions_upcoming: int = 0 145 #: Background tasks. ⚠️ ``failed`` is lifetime, not a window — the 146 #: chip is a pointer at the task list, and a failure that scrolled 147 #: out of a 24h window is still a failure nobody looked at. 148 tasks_running: int = 0 149 tasks_failed: int = 0 150 #: Notification outbox. A backed-up queue is otherwise invisible 151 #: until somebody reports never getting their mail. 152 outbox_pending: int = 0 153 outbox_failed: int = 0
Operator-health counters: is anything wrong right now?
Admin-only. Every field here is a number an operator would want to be non-zero-alerted on, which is why they sit together rather than among the population counts.
44class MetaPlatform(CtfyModel): 45 """Source-control identity of the running ctfy build. 46 47 ``commit_sha`` is None when the build couldn't determine its own 48 revision (no ``CTFY_GIT_COMMIT``, no usable git binary). 49 ``commit_url`` is built from ``repo_url`` + sha so the frontend 50 doesn't have to know the URL convention. 51 """ 52 53 commit_sha: str | None = None 54 commit_url: str | None = None 55 repo_url: str = ""
Source-control identity of the running ctfy build.
commit_sha is None when the build couldn't determine its own
revision (no CTFY_GIT_COMMIT, no usable git binary).
commit_url is built from repo_url + sha so the frontend
doesn't have to know the URL convention.
305class MetaResponse(CtfyModel): 306 version: str = "" 307 platform: MetaPlatform = Field(default_factory=MetaPlatform) 308 challenges: MetaChallenges = Field(default_factory=MetaChallenges) 309 started_at_ts: float = 0.0 310 server_time_ts: float = 0.0 311 # Cluster-wide counters — admin-only. ``None`` for non-admin 312 # callers so the frontend can distinguish "not authorised" from 313 # "happens to be zero" and hide the segment entirely instead of 314 # rendering a misleading "0 nodes". 315 teams_total: int | None = None 316 users_total: int | None = None 317 nodes_total: int | None = None 318 nodes_healthy: int | None = None 319 running_instances: int | None = None 320 solves_total: int | None = None 321 # The three admin-only groups above. ``None`` rather than a 322 # default-constructed object for the same reason every scalar 323 # beside them is: the frontend must be able to tell "not authorised" 324 # from "happens to be zero", and a zeroed group renders as a 325 # deployment with no agents, no live events and an idle gateway — 326 # a claim, where ``None`` is silence. 327 agents: MetaAgents | None = None 328 ops: MetaOps | None = None 329 llm: MetaLlm | None = None 330 # Public — every caller, guests included. See :class:`MetaEvents` 331 # for why a count of open competitions discloses nothing the 332 # catalog does not. 333 events: MetaEvents = Field(default_factory=MetaEvents) 334 # Public, like ``events`` above and for the same reason: a player 335 # meets these numbers the moment they launch anything, and the 336 # picker that reads them cannot be built without the ceiling. 337 instance_ttl: MetaInstanceTtl = Field(default_factory=MetaInstanceTtl) 338 # Per-caller. ``None`` for a signed-out visitor, who has no agents 339 # rather than zero of them — the same distinction the admin groups 340 # above draw, one identity down. 341 mine: MetaMine | None = None 342 # Per-caller, guests included: where the Contribute entry leads, or 343 # ``None`` for no entry. See :class:`MetaContribute`. 344 contribute: MetaContribute | None = None 345 # Public: a limit is a fact about the deployment, and an author 346 # deciding what to pack is entitled to it before signing in. 347 authored_archive: MetaAuthoredArchive = Field(default_factory=MetaAuthoredArchive) 348 # Public surface bit for the super-admin docker-exec feature. The 349 # frontend uses this to decide whether to render the Shell entry 350 # in the admin instance list — false means the routes aren't 351 # mounted, so any UI link would 404. Never carries the 352 # token/recording details — those stay server-side. 353 admin_shell_enabled: bool = False 354 # Where a defender's own `ssh` should knock, and — by being empty — 355 # whether the channel exists at all. ``POST /instances/{id}/ssh`` 356 # refuses with 503 unless the CA key *and* the bastion host are both 357 # configured, so these two report the same pair rather than the raw 358 # config values: advertising a host with no CA would put a command 359 # on the defence card that always 503s, which a player reads as 360 # their box being broken. Public deliberately, unlike the counters 361 # below — it is the address the player types, and knowing where to 362 # knock buys nothing without a certificate the platform mints per 363 # instance. ``ssh_bastion_port`` is 0 (not its 2222 config default) 364 # when the channel is off, so neither field can imply a bastion the 365 # other denies. 366 ssh_bastion_host: str = "" 367 ssh_bastion_port: int = 0 368 # Operator-set deployment label (``CTFY_ENVIRONMENT_LABEL``), shown 369 # as a badge next to the brand so a staging tab is visually distinct 370 # from production. Empty = no badge. Public on purpose: it names the 371 # deployment, not anything about its internals. 372 environment_label: str = "" 373 # Canonical tier derived from the label (``ctfy.core.environment``). 374 # The client keys the frame / favicon tone and the prod-only confirm 375 # friction off this instead of substring-matching the free-text label 376 # itself, so classification lives in one place. ``prod`` is the 377 # fail-safe default (the unmarked deployment is production). 378 environment: Environment = "prod"
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
1034class MilestoneProgress(CtfyModel): 1035 """Per-challenge milestone progress for the calling user. 1036 1037 Returned by ``GET /me/milestone-progress``. One row per challenge 1038 with at least one captured question, regardless of whether the 1039 challenge is fully solved. The Challenges list page renders a 1040 progress bar from ``len(solved_question_ids) / total_questions`` 1041 so a player can see "2/5 milestones" instead of an all-or-nothing 1042 solved badge. 1043 1044 Like ``MySolveSummary`` this is user-scoped: ``solved_question_ids`` 1045 aggregates every question id the user captured for the challenge 1046 across every team they have ever been on. The optional 1047 ``competition_id`` query param narrows the aggregation to solves 1048 stamped against the user's team in that comp. 1049 """ 1050 1051 challenge_id: str 1052 solved_question_ids: list[str] = Field(default_factory=list) 1053 total_questions: int = 0
Per-challenge milestone progress for the calling user.
Returned by GET /me/milestone-progress. One row per challenge
with at least one captured question, regardless of whether the
challenge is fully solved. The Challenges list page renders a
progress bar from len(solved_question_ids) / total_questions
so a player can see "2/5 milestones" instead of an all-or-nothing
solved badge.
Like MySolveSummary this is user-scoped: solved_question_ids
aggregates every question id the user captured for the challenge
across every team they have ever been on. The optional
competition_id query param narrows the aggregation to solves
stamped against the user's team in that comp.
57class ModelStanding(CtfyModel): 58 """One model's overall standing plus its per-dimension breakdowns.""" 59 60 rank: int = 0 61 model_id: str = "" 62 display_name: str = "" 63 #: SDK wire protocol the platform dials this model with — an 64 #: *addressing* detail with no bearing on the score. 65 #: 66 #: ⚠️ **Not the vendor**, though both its values are vendor names: a 67 #: DeepSeek model served over the anthropic dialect carries 68 #: ``flavor="anthropic"``. Anything rendering this unlabelled beside a 69 #: model name is publishing a false attribution. 70 flavor: FlavorOrUnset = "" 71 #: owning vendor / org slug (blank when unattributed). 72 vendor: str = "" 73 #: the vendor's curated display name, falling back to the slug. 74 #: 75 #: ⚠️ Blank **only** when ``vendor`` is blank, so a reader can tell 76 #: "unattributed" from "attributed to a vendor with no curated row" — 77 #: the second is a label, the first is silence. 78 vendor_display_name: str = "" 79 #: provider model id actually requested (e.g. ``deepseek-chat``). 80 provider_model: str = "" 81 #: the harnesses whose runs this row pools, busiest first. 82 harnesses: list[ModelHarnessRef] = Field(default_factory=list) 83 runs: int = 0 84 challenges_attempted: int = 0 85 #: Challenges the platform never presented to this model at all — a 86 #: range that would not start, a node that answered 5xx, a fleet with 87 #: no capacity. ⚠️ Published beside ``challenges_attempted`` because 88 #: ``solve_rate`` alone cannot express it: the rate has always 89 #: divided by challenges that produced a run, so it is honest and 90 #: says nothing about how much of the set it covers. Measured: a 50% 91 #: over 26 challenges ranked beside a 66% over 102. 92 challenges_errored: int = 0 93 challenges_solved: int = 0 94 solve_rate: float = 0.0 95 questions_solved: int = 0 96 input_tokens: int = 0 97 output_tokens: int = 0 98 #: input + output across all the model's succeeded runs. 99 total_tokens: int = 0 100 #: cost-efficiency: total tokens per solved challenge (0 if none solved). 101 tokens_per_solve: float = 0.0 102 #: estimated USD cost (tokens × the model's price); 0 when unpriced. 103 cost_usd: float = 0.0 104 #: USD per solved challenge (0 when unpriced or nothing solved). 105 cost_per_solve: float = 0.0 106 avg_turns: float = 0.0 107 #: deepest pass@k sampling — max succeeded runs against any one challenge. 108 max_samples: int = 0 109 #: corpus version these runs were graded against (the model's modal sha). 110 corpus_sha: str = "" 111 #: dimension name (``difficulty`` / ``bucket`` / ``tag``) → its cells. 112 breakdowns: dict[str, list[LeaderboardCell]] = Field(default_factory=dict)
One model's overall standing plus its per-dimension breakdowns.
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).
1203class MyEvalRunPage(CtfyModel): 1204 """A page of runs, plus the same two envelope facts as the calls.""" 1205 1206 items: list[MyEvalRunRow] = Field(default_factory=list) 1207 total: int = 0 1208 indexing: bool = True
A page of runs, plus the same two envelope facts as the calls.
812class MyEvalRunRow(CtfyModel): 813 """One of your (or a team-mate's) evaluation runs, for the shelf. 814 815 ⚠️ **``harness_id`` and ``model_id``, never the synthetic entrant 816 id.** ``EvalRunState.team_id`` is a one-person team minted per 817 ``(entrant, competition)``, so a page that showed it would print a 818 string nobody recognises and imply a team that has no members. 819 820 ⚠️ ``mine`` is composed **server-side**. Both the run's author and 821 the caller reach the browser on other surfaces, so a component 822 *could* compare them — and then two clients would disagree about 823 whether the conversation button is enabled. Same argument 824 ``expects_report`` makes. 825 """ 826 827 #: How many times this entrant has rebuilt this challenge's 828 #: environment, from the permanent instance history. 829 #: 830 #: ⚠️ **``None`` means this surface did not say**, never "never 831 #: reset". The shelf withholds it — the count is a point query per 832 #: ``(team, challenge)`` and a fifty-row page would pay it fifty 833 #: times for a number nobody asked for — so only the single-run read 834 #: composes it. A ``0`` default would make the two surfaces disagree 835 #: about a run that *had* been reset, which is the one comparison 836 #: this field exists for: a model that reset five times and one that 837 #: never did are not like for like, and until now that difference was 838 #: invisible in the data. 839 resets: int | None = None 840 841 id: str = "" 842 harness_id: str = "" 843 model_id: str = "" 844 competition_id: str = "" 845 challenge_id: str = "" 846 #: The estate this run was dispatched against. A foreign key, so 847 #: ``instance_id`` rather than ``id`` — and the field the traffic 848 #: read is addressed by, which is why the row carries it at all: the 849 #: run knew which box it drove and the projection did not, so 850 #: nothing on the shelf could link to it. 851 instance_id: str = "" 852 status: str = "" 853 created_at_ts: float = 0.0 854 #: Whose run this is, for a team-wide list. 855 owner_user_id: str = "" 856 #: What that person is called, resolved for display — never the id, 857 #: which is already on the wire beside it. ``""`` means *nothing to 858 #: show*, never "we did not look": `ctfy.server.entity_labels` is the 859 #: one producer, and its third state is the whole reason this is not 860 #: composed ``label or owner``, which is what it was. 861 #: 862 #: ⚠️ **What that blank actually means here is "no display name", not 863 #: the convention's usual "deleted account".** `readable_runs` 864 #: derives its audience from membership rows and `delete_user` 865 #: cascades them, so a deleted owner drops out of the audience with 866 #: their run rather than appearing unnameable — measured, and pinned 867 #: by `test_a_deleted_account_is_not_listed_at_all`. Reading this 868 #: field's blank as a tombstone would be a sentence about a state 869 #: this route cannot reach. 870 #: 871 #: ⚠️ **`player_label`, so never an address.** The audience is the 872 #: caller's team-mates *at one competition* (`list_my_runs`), and 873 #: the roster they already open publishes a display name and no 874 #: email — `_author_name`'s split on the same audience. 875 owner_display_name: str = "" 876 #: Whether *this* caller may open the conversations. The list is the 877 #: team's; the bodies are the author's. 878 mine: bool = False 879 #: Why this run's traffic cannot be read, or ``""`` when it can. 880 #: 881 #: ⚠️ **A code, not a bool, and the client tests membership of 882 #: :data:`RUN_TRAFFIC_BLOCKED` rather than truthiness.** The three 883 #: closed cases each need their own sentence, and — the half that 884 #: decides the type — an older server sends no field at all, which 885 #: arrives as ``""``. A ``traffic_available: bool`` read as 886 #: ``=== false`` would then be *false* for everyone and the control 887 #: would vanish platform-wide with nothing reporting it. An 888 #: unrecognised value is likewise not blocked, so a server that 889 #: learns a fourth reason degrades to a live button rather than a 890 #: missing one. 891 traffic: str = "" 892 #: Why this run's collected archive cannot be downloaded, or ``""`` 893 #: when it can. 894 #: 895 #: ⚠️ **Same type and same reading as ``traffic`` one field up**, and 896 #: for the same reasons: the closed cases are the set, everything 897 #: outside it is open, and an older server's absent field arrives as 898 #: ``""`` and must read as *not blocked*. 899 #: 900 #: ⚠️ Fail-open here means a run from before collection existed 901 #: offers a button that answers 404. That is the accepted trade this 902 #: repository makes everywhere: a wrong ``True`` costs a click, a 903 #: wrong ``False`` takes the control away from everybody the day the 904 #: server learns a new reason. 905 artifact: str = "" 906 #: Size of the stored archive, so the page can say what a click will 907 #: cost before it costs it. ``0`` when there is nothing to download. 908 artifact_bytes: int = 0 909 #: Why this run's write-up cannot be read, or ``""`` when it can. 910 #: 911 #: ⚠️ **A code and not the prose, because the prose is capped at 912 #: 100 000 characters** and this row rides a page of fifty. Same 913 #: split the archive makes one field up: the shelf says whether 914 #: there is one, the text is addressed. 915 #: 916 #: ⚠️ Same type and same reading as ``traffic`` and ``artifact`` 917 #: above — the closed cases are the set, everything outside it is 918 #: open, and an older server's absent field arrives as ``""`` and 919 #: must read as *not blocked*. 920 writeup: str = "" 921 #: What the run has spent so far, from the run row itself. 922 input_tokens: int = 0 923 output_tokens: int = 0 924 #: How many calls the gateway actually relayed for this run. ``0`` 925 #: beside a ``usage_source`` of ``agent`` is the pair that says the 926 #: two token counts above are the agent's own word about itself. 927 relayed_calls: int = 0 928 #: Who counted those figures — ``gateway`` where this deployment 929 #: measured them, ``agent`` where the run reported its own. 930 #: 931 #: ⚠️ **The default withholds, and ``"agent"`` would be a claim in 932 #: the accusing direction.** The shelf's warning line keys on 933 #: ``agent``, so a producer that forgot to forward this field would 934 #: accuse every row of a working deployment. ``""`` is *this server 935 #: did not say* — which is also how an older server's absent field 936 #: arrives — and it is what lets 937 #: `TestWhatTheRunSpentIsTheCountedNumber`'s unmeasured arm fail at 938 #: all: a default of ``agent`` agrees with the value that arm 939 #: asserts, so the mutation deleting both forwarding lines left it 940 #: green and only the measured arm reddened. 941 #: 942 #: ⚠️ **``str``, never a ``Literal``** — `KeySpec`'s reason. The one 943 #: reader compares *positively* against ``agent``, so a provenance a 944 #: newer server learns degrades to silence rather than becoming 945 #: unrepresentable in the client's own fail-quiet test. 946 usage_source: str = "" 947 #: Whether this run's **entrant** is on the event's projector wall. 948 #: 949 #: ⚠️ **A property of the entrant, not of the run**, and the field 950 #: says so rather than pretending otherwise: 951 #: ``TeamState.wall_published_at`` is per ``(owner, harness, model, 952 #: competition)`` because ``llm_calls`` has no run column, so a 953 #: second run of the same agent in the same event reads the same 954 #: value. The shelf renders the control per row anyway — a row is 955 #: where an author is standing — and the copy names the agent 956 #: rather than the run. 957 #: 958 #: ⚠️ **Defaults ``False``** because that is the state an author 959 #: never asked to leave: a producer that forgot this field would 960 #: otherwise draw a row as published on a deployment where nothing 961 #: is. The opposite default is a new claim, and here the claim is 962 #: somebody's consent. 963 wall_published: bool = False 964 #: Why this run's author cannot put it on the wall, or ``""`` when 965 #: they can. 966 #: 967 #: ⚠️ **Composed server-side**, ``mine`` and ``traffic``'s rule: 968 #: every fact it reads reaches the client some other way, so a 969 #: component *could* derive it — and then two clients would 970 #: disagree about whether the control is live, in the invisible 971 #: direction. Each member needs 972 #: ``settings:agentRuns.wall.<code>`` copy in both locales; the key 973 #: is computed at the call site, so neither frontend i18n scanner 974 #: can see it and ``test_run_wall_copy_parity.py`` pins it from 975 #: :data:`RUN_WALL_BLOCKED` instead. 976 wall: str = "" 977 #: Whether this run's **entrant** has published its full conversation 978 #: (B2). ``TeamState.transcript_published_at``, the author's second, 979 #: stronger consent — the wall shows one line, this opens the whole 980 #: scrubbed conversation. Defaults ``False`` for ``wall_published``'s 981 #: reason: the state an author never asked to leave. 982 transcript_published: bool = False 983 #: Why this run's author cannot publish the full conversation, or 984 #: ``""`` when they can. Composed server-side, ``wall``'s rule. Its 985 #: extra member over the wall is ``anonymous``: a conversation 986 #: de-anonymises, so a pseudonymous/hidden entrant — publishable to 987 #: the wall under an alias — can never publish a transcript. Each 988 #: member needs ``settings:agentRuns.transcript.<code>`` copy in both 989 #: locales; ``test_run_wall_copy_parity.py`` pins it from 990 #: :data:`RUN_TRANSCRIPT_BLOCKED`. 991 transcript: str = ""
One of your (or a team-mate's) evaluation runs, for the shelf.
⚠️ harness_id and model_id, never the synthetic entrant
id. EvalRunState.team_id is a one-person team minted per
(entrant, competition), so a page that showed it would print a
string nobody recognises and imply a team that has no members.
⚠️ mine is composed server-side. Both the run's author and
the caller reach the browser on other surfaces, so a component
could compare them — and then two clients would disagree about
whether the conversation button is enabled. Same argument
expects_report makes.
459class MyLlmKey(CtfyModel): 460 """One of the caller's own upstream credentials, as they may see it. 461 462 ⚠️ **There is no ``api_key`` field, and its absence is the design.** 463 A user pastes a provider key once; every read after that is a list 464 they can tell apart, never one they can copy — and neither can 465 anybody who reaches this endpoint holding their bearer. A field 466 carrying ``""`` would satisfy every "the secret is not in the 467 response" test while leaving the next writer one line from 468 populating it. 469 """ 470 471 flavor: FlavorOrUnset = "" 472 label: str = "" 473 base_url: str = "" 474 #: The key's last four characters, so a rotated key is 475 #: distinguishable from its predecessor. The *suffix*: provider keys 476 #: carry a recognisable prefix that says nothing about which key this 477 #: is, and the tail is what every provider's own console shows. 478 fingerprint: str = "" 479 added_at: str = "" 480 #: ``""`` means no run has spent it yet — a real answer, not a 481 #: missing one. 482 last_used_at: str = "" 483 #: What the last probe of this credential found, and when. 484 #: 485 #: ⚠️ **A pair, and both are rendered or neither.** A stored code 486 #: with no instant is the stale-marker trap: a key rejected once and 487 #: rotated since would read ``auth_failed`` for ever with nothing 488 #: saying the reading predates the fix. 489 #: 490 #: ⚠️ Both ``""`` is *never probed*, which is not a tenth verdict — 491 #: ``gateway_unreachable`` already means *a probe happened and this 492 #: deployment could not tell you*, and the two get opposite advice. 493 last_probe_code: str = "" 494 last_probe_at: str = ""
One of the caller's own upstream credentials, as they may see it.
⚠️ There is no api_key field, and its absence is the design.
A user pastes a provider key once; every read after that is a list
they can tell apart, never one they can copy — and neither can
anybody who reaches this endpoint holding their bearer. A field
carrying "" would satisfy every "the secret is not in the
response" test while leaving the next writer one line from
populating it.
537class MyLlmKeyCreate(CtfyModel): 538 """A credential as its owner types it. 539 540 ⚠️ The dialect is **not** here: it is the path segment, so a body 541 naming one dialect while the URL names another is unrepresentable 542 rather than a precedence rule somebody has to remember. 543 """ 544 545 api_key: str = Field(min_length=1, max_length=512) 546 label: str = Field(default="", max_length=64) 547 base_url: str = Field(default="", max_length=200)
A credential as its owner types it.
⚠️ The dialect is not here: it is the path segment, so a body naming one dialect while the URL names another is unrepresentable rather than a precedence rule somebody has to remember.
497class MyLlmKeyProbe(CtfyModel): 498 """What one press of Test on one of the caller's own keys found. 499 500 ⚠️ **No ``key_id``**, unlike the operator's :class:`LlmProviderProbe`. 501 The client never holds one — it addresses a key by 502 ``(flavor, base_url)``, which is the identity the server keys on — 503 so publishing the derived ``byok:<uuid>:…`` id back would put an 504 internal id shape on the wire for no reader at all. The pair it did 505 send is echoed instead, so a card keying its results by provider can 506 file the answer without trusting request order. 507 508 ⚠️ **``ok`` defaults ``False`` and ``code`` defaults ``""``**, the 509 not-a-new-claim direction: a producer that forgets must not report a 510 credential as working. 511 """ 512 513 flavor: FlavorOrUnset = "" 514 base_url: str = "" 515 ok: bool = False 516 #: The bucket to act on. ``ok`` / ``auth_failed`` / ``quota_exceeded`` 517 #: / ``unreachable`` / ``bad_request`` from the provider, 518 #: ``no_gateway`` / ``gateway_unreachable`` / ``not_synced`` / 519 #: ``unknown`` from this platform, and ``no_model_registered`` when 520 #: nothing a run could dispatch names this address. 521 #: 522 #: ⚠️ A **code, never a boolean**: each one sends the reader to a 523 #: different field, and one red cross reports them identically. 524 code: str = "" 525 #: The provider's verbatim status; ``0`` when nothing answered. 526 status: int = 0 527 #: The provider's own words, bounded by the gateway. 528 detail: str = "" 529 #: What the model actually said. ⚠️ Empty is a *result* — a 200 that 530 #: generated no text is the shape a status code cannot see. 531 reply: str = "" 532 #: The gateway is running its offline stub, so ``ok`` here proves 533 #: nothing about a real provider. Rendered **beside** the verdict. 534 stub: bool = False
What one press of Test on one of the caller's own keys found.
⚠️ No key_id, unlike the operator's LlmProviderProbe.
The client never holds one — it addresses a key by
(flavor, base_url), which is the identity the server keys on —
so publishing the derived byok:<uuid>:… id back would put an
internal id shape on the wire for no reader at all. The pair it did
send is echoed instead, so a card keying its results by provider can
file the answer without trusting request order.
⚠️ ok defaults False and code defaults "", the
not-a-new-claim direction: a producer that forgets must not report a
credential as working.
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.
136class MyRegistrationResponse(CtfyModel): 137 """``GET /competitions/{id}/registration`` — the caller's own dossier. 138 139 Carries the form spec alongside the values so the wizard can render 140 from one round trip, and ``missing_required`` so it can tell the user 141 exactly what still blocks submission without reimplementing the 142 server's rule. 143 """ 144 145 competition_id: str 146 form: RegistrationFormSpec 147 registered: bool = False 148 submitted_at: datetime | None = None 149 updated_at: datetime | None = None 150 missing_required: list[str] = Field(default_factory=list) 151 152 real_name: str = "" 153 phone: str = "" 154 mailing_address: str = "" 155 # ⚠️ The same closed set the request carries, not a bare ``str``. 156 # It was ``str`` here while ``RegistrationSubmitRequest.gender`` 157 # was a ``Literal`` and the storage column was too — three 158 # layers with the middle one dropping the type, the shape 159 # ``ScopeCatalogEntry.max_level`` already records. A client 160 # reads this and writes that, so the loose middle made the 161 # round trip fail to type-check in the one direction that 162 # matters: `tsc` reported it the moment the wire type stopped 163 # being hand-written on the frontend. 164 gender: Literal["", "male", "female", "other", "undisclosed"] = "" 165 organization: str = "" 166 academic_stage: str = "" 167 grade: str = "" 168 undergrad_school: str = "" 169 specialties: list[str] = Field(default_factory=list) 170 171 # Team-scoped answers, echoed so the captain's wizard round-trips. 172 team_id: str = "" 173 team_name: str = "" 174 team_honors: str = "" 175 team_has_logo: bool = False 176 is_captain: bool = False 177 review_status: RegistrationReviewStatus = "approved" 178 review_note: str = "" 179 #: The two anonymity answers as stored, so the wizard round-trips. 180 #: Booleans here rather than the request's tri-state: this is what 181 #: *is*, and "the form did not ask" is not a state a stored row can 182 #: be in. 183 anonymous: bool = False 184 team_anonymous: bool = False
GET /competitions/{id}/registration — the caller's own dossier.
Carries the form spec alongside the values so the wizard can render
from one round trip, and missing_required so it can tell the user
exactly what still blocks submission without reimplementing the
server's rule.
151class MyReportRow(CtfyModel): 152 """One range this team has written up, as the team's own index reads it. 153 154 ⚠️ **Not a narrowed :class:`ReportRosterRow`.** That one answers "who 155 has started" for an organiser and carries a team; this answers "which 156 of my ranges have I written up" and every row is the caller's own, so 157 a team column there would be the same value repeated down the page. 158 """ 159 160 challenge_id: str = "" 161 version: int = 0 162 length: int = 0 163 saved_at: datetime | None = None 164 #: Where this range's write-up stands with the report grader. 165 status: ReportGradeStatus = "not_scored" 166 #: 1-based place in the grading queue — **only** while ``status`` is 167 #: ``queued``. 168 #: 169 #: ⚠️ ``0`` is *no position*, and it covers two facts on purpose: 170 #: this report is not queued, and this competition is silent so the 171 #: number is withheld. They are told apart by ``status``, which is 172 #: the field that already has to be read first — and collapsing them 173 #: is what keeps a client from rendering "#0 in queue", which is the 174 #: one output a separate "unknown" member would invite. 175 queue_position: int = 0 176 #: When this team handed this range in, or ``None``. 177 finalized_at: datetime | None = None 178 #: Grading requests this team has spent on this range — the 179 #: numerator behind the button's "2 left". 180 #: 181 #: ⚠️ **Per row, because the quota is.** It is keyed 182 #: ``(team, range)``, so a team that has spent everything on one 183 #: estate still has its full allowance on the next — and a single 184 #: number on the response would say the opposite. 185 grade_requests_used: int = 0
One range this team has written up, as the team's own index reads it.
⚠️ Not a narrowed ReportRosterRow. That one answers "who
has started" for an organiser and carries a team; this answers "which
of my ranges have I written up" and every row is the caller's own, so
a team column there would be the same value repeated down the page.
188class MyReportsResponse(CtfyModel): 189 """This team's report index for one competition. 190 191 ⚠️ **Only ranges with a saved report appear.** The ranges the team 192 has *not* written up are the competition's challenge list minus 193 these, which the client already holds — and listing them here would 194 mean this route deciding what "the competition's ranges" are, a 195 second answer to a question ``effective_challenge_ids`` already owns. 196 """ 197 198 competition_id: str = "" 199 rows: list[MyReportRow] = Field(default_factory=list) 200 #: How many reports are queued for grading across the whole 201 #: competition — the denominator behind ``queue_position``. 202 #: 203 #: ⚠️ **Served, unlike the organiser's, because this client cannot 204 #: count it.** Every row here is the caller's own team, so a total 205 #: derived from them would say "1 of 1" for a team third in a queue 206 #: of twelve. 207 #: 208 #: ⚠️ **Zero in a silent competition**, along with every 209 #: ``queue_position``. How many teams have a report in flight is the 210 #: field's progress, which is the thing that format exists to 211 #: withhold — the same read as ``solves_count`` and ``flag_solves``. 212 #: The per-row ``status`` survives, because "your report is queued" 213 #: is a fact about the caller's own submission and about nobody 214 #: else's. 215 queue_length: int = 0 216 #: The per-range ceiling on hand-pushed grading. ⚠️ **``0`` is 217 #: unlimited**, and it is served rather than restated in the client 218 #: for ``ReportAttachmentsResponse.remaining``'s reason: a ceiling 219 #: spelled in two places is one a client eventually disagrees with, 220 #: and the direction it disagrees in is a disabled button on a 221 #: deployment that had room. 222 #: 223 #: ⚠️ **Not withheld in a silent competition.** It is a fact about 224 #: the caller's own allowance, not about the field — the same split 225 #: that keeps ``status`` while dropping ``queue_position``. 226 grade_requests_limit: int = 0
This team's report index for one competition.
⚠️ Only ranges with a saved report appear. The ranges the team
has not written up are the competition's challenge list minus
these, which the client already holds — and listing them here would
mean this route deciding what "the competition's ranges" are, a
second answer to a question effective_challenge_ids already owns.
996class MySolveSummary(CtfyModel): 997 """Per-challenge solve summary for the calling **user**. 998 999 Returned by ``GET /me/solves``. One row per challenge the caller 1000 personally captured at least one question of. ``best_rank`` is the 1001 smallest rank across those captures — drives the 1血/2血/3血 badge. 1002 Multi-flag challenges may have different ranks per flag; reporting 1003 the best one gives players credit for whichever piece they nailed 1004 first. 1005 1006 ⚠️ **The subject is the person, and this docstring said "team" for 1007 long enough that a client believed it.** The route filters 1008 ``Solve.user_id`` — the human who submitted — while every scoring 1009 surface on this platform keys on ``team_id``, so a team-mate who had 1010 not personally submitted got no row at all and the challenge card 1011 rendered 未解出 over a challenge their team already owned. That is a 1012 profile page's honest answer and a challenge card's wrong one; the 1013 card reads ``ChallengeInfo.my_team_solved_question_ids`` now, and 1014 this stays what it always was. 1015 """ 1016 1017 challenge_id: str 1018 best_rank: int 1019 solved_at: datetime | None = None 1020 # Whether **every** capture the team holds on this challenge is on 1021 # the competition's board. 1022 # 1023 # ⚠️ ``all``, not ``any``: the card renders this as "some of what you 1024 # captured here does not count", and a partial split has to say so. 1025 # ``any`` would stay quiet about two uncounted flags out of three, 1026 # which is the same silence as before the field existed. 1027 # 1028 # Unscoped (``competition_id=""``) this is always ``True``: the 1029 # cross-competition history applies no window, so no capture there 1030 # is "not counted" — see ``/me/solves``. 1031 ranked: bool = True
Per-challenge solve summary for the calling user.
Returned by GET /me/solves. One row per challenge the caller
personally captured at least one question of. best_rank is the
smallest rank across those captures — drives the 1血/2血/3血 badge.
Multi-flag challenges may have different ranks per flag; reporting
the best one gives players credit for whichever piece they nailed
first.
⚠️ The subject is the person, and this docstring said "team" for
long enough that a client believed it. The route filters
Solve.user_id — the human who submitted — while every scoring
surface on this platform keys on team_id, so a team-mate who had
not personally submitted got no row at all and the challenge card
rendered 未解出 over a challenge their team already owned. That is a
profile page's honest answer and a challenge card's wrong one; the
card reads ChallengeInfo.my_team_solved_question_ids now, and
this stays what it always was.
1113class NodeAuthoredPush(CtfyModel): 1114 """Platform → node: put this approved upload in your catalog. 1115 1116 ⚠️ **The bytes travel, and that is the one thing this cannot borrow 1117 from :class:`NodeRangePush`.** That model sends a *scenario* and 1118 argues that files must not cross the node API — right, because the 1119 corpus is mounted on the node and the compiler ships inside it. A 1120 player-authored tree has neither: the files *are* the source, the 1121 same reason `AuthoredChallengeState` stores bytes where 1122 `GeneratedRangeState` stores knobs. 1123 1124 ⚠️ **An archive rather than the stored `content_b64` map**, because 1125 that map is the tree as base64, uncompressed — a POST a third larger 1126 than `MAX_TOTAL_BYTES` at the ceiling — and 1127 because `read_archive` + `plan_extraction` are then the *same* 1128 producers the upload door used, re-asked on the machine where an 1129 escaped member would be an arbitrary write on a worker. 1130 """ 1131 1132 challenge_id: str 1133 #: `base64(pack_tree(...))` — a gzipped tar carrying exactly one 1134 #: leading directory, the shape `read_archive` strips. 1135 archive_b64: str = "" 1136 #: `tree_revision` of the tree the archive carries (#1835). The node 1137 #: re-derives it from what it extracted and refuses a disagreement, so 1138 #: the name it later answers a start with is the name of what it holds. 1139 #: ``""`` — what an older platform sends — asserts nothing and is 1140 #: checked against nothing. 1141 revision: str = ""
Platform → node: put this approved upload in your catalog.
⚠️ The bytes travel, and that is the one thing this cannot borrow
from NodeRangePush. That model sends a scenario and
argues that files must not cross the node API — right, because the
corpus is mounted on the node and the compiler ships inside it. A
player-authored tree has neither: the files are the source, the
same reason AuthoredChallengeState stores bytes where
GeneratedRangeState stores knobs.
⚠️ An archive rather than the stored content_b64 map, because
that map is the tree as base64, uncompressed — a POST a third larger
than MAX_TOTAL_BYTES at the ceiling — and
because read_archive + plan_extraction are then the same
producers the upload door used, re-asked on the machine where an
escaped member would be an arbitrary write on a worker.
1144class NodeAuthoredResult(CtfyModel): 1145 """Node → platform: what this node did with a pushed upload. 1146 1147 ⚠️ ``files`` rather than a bare ``ok``: a tree that wrote zero files 1148 and one that wrote forty both answer "ok", and only the count 1149 separates a real push from an archive that planned nothing. 1150 """ 1151 1152 challenge_id: str = "" 1153 ok: bool = False 1154 #: How many files landed. `0` on any refusal. 1155 files: int = 0 1156 #: The revision this node now holds, derived from the files it wrote. 1157 #: ``""`` on any refusal. 1158 revision: str = "" 1159 #: Why not. Empty on success — never a generic failure, because the 1160 #: repairs differ: a refused member is fixed by the author, an 1161 #: unwritable catalog on the node's host. 1162 error: str = ""
1165class NodeAuthoredSmokePush(CtfyModel): 1166 """Platform → node: run this authored challenge's own exploit here. 1167 1168 ⚠️ **The id, not the tree.** Unlike :class:`NodeAuthoredPush` there 1169 is nothing to send: the smoke run is *of the copy this node already 1170 holds*, and a push that carried bytes would be smoking an estate no 1171 player will ever launch. 1172 """ 1173 1174 challenge_id: str 1175 #: Seconds. `0` means the node's own default — never "no limit", 1176 #: which on a wedged compose is a worker slot held for ever. A 1177 #: *per-step* budget (`ctfy.core.smoke_budget`), so the platform 1178 #: sends it explicitly and waits `worst_case_seconds` of it. 1179 timeout_s: int = 0 1180 #: Leave nothing behind (plan P2.2): teardown removes every image the 1181 #: run used, pulled ones included, and the build cache is pruned. 1182 #: Sent for a quarantine node only — whose contract is that one 1183 #: unread tree cannot hand anything to the next — and never for the 1184 #: published pool, where the cache is what keeps a player's launch 1185 #: fast. 1186 reset: bool = False 1187 #: The revision the run must be of (#1835): a node holding another 1188 #: fetches the current tree first, so a smoke never reports on bytes 1189 #: that are no longer the challenge. ``""`` = whatever the node holds. 1190 revision: str = ""
Platform → node: run this authored challenge's own exploit here.
⚠️ The id, not the tree. Unlike NodeAuthoredPush there
is nothing to send: the smoke run is of the copy this node already
holds, and a push that carried bytes would be smoking an estate no
player will ever launch.
1193class NodeAuthoredSmokeResult(CtfyModel): 1194 """Node → platform: what the smoke run concluded. 1195 1196 ⚠️ ``verdict`` rather than a bare ``ok``: the four outcomes are four 1197 repairs by different people — see :data:`AuthoredSmokeVerdict`. The 1198 exit code rides along because an ``errored`` verdict on a code we do 1199 not recognise is only actionable if the reader can see the code. 1200 """ 1201 1202 challenge_id: str = "" 1203 verdict: AuthoredSmokeVerdict = "errored" 1204 #: `smoke_benchmark`'s own exit code. `-1` when it never ran. 1205 exit_code: int = -1 1206 error: str = ""
Node → platform: what the smoke run concluded.
⚠️ verdict rather than a bare ok: the four outcomes are four
repairs by different people — see AuthoredSmokeVerdict. The
exit code rides along because an errored verdict on a code we do
not recognise is only actionable if the reader can see the code.
1287class NodeAuthoredSyncLine(CtfyModel): 1288 """One node's outcome in an authored-upload push. 1289 1290 ⚠️ **Its own model rather than :class:`NodeRangeSyncLine` with an 1291 empty `corpus_commit`.** That field is the whole point of the range 1292 line — two nodes can both succeed and hold different estates, and 1293 only the commit says so — and it has no meaning here, where the 1294 bytes are what travelled. A field that asserts nothing on one of two 1295 users of a shared model is the `CompetitionAccess` trap: one name, 1296 two axes. ``files`` is this push's equivalent witness. 1297 1298 ⚠️ Three states, not two: ``ok``, refused (``error`` from the node), 1299 and unreachable (``error`` from the transport) — the repairs are on 1300 different machines. 1301 """ 1302 1303 node_id: str = "" 1304 ok: bool = False 1305 #: How many files the node wrote. `0` on any refusal. 1306 files: int = 0 1307 error: str = ""
One node's outcome in an authored-upload push.
⚠️ Its own model rather than NodeRangeSyncLine with an
empty corpus_commit. That field is the whole point of the range
line — two nodes can both succeed and hold different estates, and
only the commit says so — and it has no meaning here, where the
bytes are what travelled. A field that asserts nothing on one of two
users of a shared model is the CompetitionAccess trap: one name,
two axes. files is this push's equivalent witness.
⚠️ Three states, not two: ok, refused (error from the node),
and unreachable (error from the transport) — the repairs are on
different machines.
176class NodeCaptureStoreReport(CtfyModel): 177 """What a node's capture store holds, and what its last pass did (#1714). 178 179 The node measures ``<proxy_output_host_root>/`` — the per-instance 180 tcpdump and mitmproxy output it writes and nothing else removes — 181 and sends the latest reading on every heartbeat, because the platform 182 cannot see another host's disk and the admin overview is where an 183 operator looks. 184 185 ⚠️ **``None`` on the heartbeat is "not measured", never zero.** A node 186 from before this field, or one that has not finished its first pass, 187 sends nothing; a zero here is a measurement. 188 """ 189 190 #: Unix seconds the store was last walked. 191 measured_at: float = 0.0 192 status: PassStatus = PassStatus.OK 193 #: The capture root on the node's own filesystem; ``""`` with 194 #: ``status=no_store`` (captures live in instance workdirs). 195 root: str = "" 196 instances: int = 0 197 total_bytes: int = 0 198 #: Held by instances no box is running from — the most any window 199 #: can reclaim. 200 finished_bytes: int = 0 201 #: Of those, past the retention window when the pass planned. 202 due_bytes: int = 0 203 #: The window and cap this pass applied. ``None`` is *the platform 204 #: did not say*, which reclaims nothing. 205 retention_s: int | None = None 206 cap_bytes: int | None = None 207 reclaimed_instances: int = 0 208 reclaimed_bytes: int = 0 209 #: Removals that failed (a permission, a vanished file). The rest of 210 #: the pass went ahead; these are retried next pass. 211 failed: int = 0 212 kept: dict[KeepReason, int] = Field(default_factory=dict) 213 skipped: dict[SkipReason, int] = Field(default_factory=dict) 214 #: Directories whose capture the per-instance cap has trimmed, and 215 #: the bytes it dropped — read off each one's truncation marker, so 216 #: this is what the disk says rather than what one process counted. 217 truncated_instances: int = 0 218 truncated_bytes: int = 0 219 #: Live instances still over the cap after every closed segment went 220 #: (only tcpdump's open files remain). 221 over_cap_instances: int = 0 222 #: Why the pass did less than its status suggests, when it did. 223 error: str = ""
What a node's capture store holds, and what its last pass did (#1714).
The node measures <proxy_output_host_root>/ — the per-instance
tcpdump and mitmproxy output it writes and nothing else removes —
and sends the latest reading on every heartbeat, because the platform
cannot see another host's disk and the admin overview is where an
operator looks.
⚠️ None on the heartbeat is "not measured", never zero. A node
from before this field, or one that has not finished its first pass,
sends nothing; a zero here is a measurement.
280class NodeCaptureStoreRow(CtfyModel): 281 """One node's capture store, as its last heartbeat reported it.""" 282 283 node_id: str 284 node_name: str = "" 285 #: When the platform received the reading; ``0`` = never, since the 286 #: platform started. 287 received_at: float = 0.0 288 #: ``None`` = not measured (an older node, or its first pass has not 289 #: finished). Never read as an empty store. 290 report: NodeCaptureStoreReport | None = None
One node's capture store, as its last heartbeat reported it.
267class NodeHeartbeat(CtfyModel): 268 """Body for ``POST /nodes/heartbeat``. 269 270 Node reports its live counts + resource utilisation on every beat. 271 The percentages are 0–100; 0 is a safe default for the first beat 272 where psutil hasn't had a prior sample to diff against. 273 """ 274 275 id: str 276 running: int = 0 277 capacity: int = 50 278 cpu_percent: float = 0.0 279 memory_percent: float = 0.0 280 # Physical RAM in MiB (not a percentage). Memory-aware admission 281 # needs the absolute size; 0 = not reported, which older node builds 282 # send and which admission reads as "unknown, fall back to slots". 283 memory_total_mb: float = 0.0 284 disk_percent: float = 0.0 285 # Unix seconds when the node sampled the metrics. The server stores 286 # ``received_at - sampled_at`` as one-way latency on the resulting 287 # health sample. Default 0 means "not measured" — older node builds 288 # that haven't been upgraded keep working. 289 sampled_at: float = 0.0 290 # The ctfy version the node itself is running. Empty on a node that 291 # predates the field, which reads as "unknown" and is never a skew — 292 # the same rule ``memory_total_mb``'s 0 already follows, so an 293 # un-upgraded fleet stays quiet instead of alarming on every beat. 294 version: str = "" 295 # What this node is actually running, as two facts `version` cannot 296 # carry. ⚠️ **`""` is *unreadable*, never a mismatch** — see the 297 # same pair on :class:`NodeInfo` for the deployment this was 298 # measured on and why semver is not enough. 299 git_commit: str = "" 300 corpus_commit: str = "" 301 # Base64 X25519 public key for this node's half of an AWD match 302 # tunnel. Bounded here rather than validated: the *shape* is checked 303 # once, at plan time, where a refusal can name the node and the match 304 # it broke (`core/awd_fabric.py`); a second validator on the hottest 305 # node→platform path would be a second answer to one question. The 306 # length cap is not that validator — it stops an unbounded string 307 # reaching the column at all. 308 wireguard_public_key: str = Field(default="", max_length=64) 309 #: Host other nodes dial for the fabric. ⚠️ A host, not a 310 #: ``host:port``: the port is the fabric plan's, and a node 311 #: naming one would be a second answer to a question the plan 312 #: already settles. 313 wireguard_host: str = Field(default="", max_length=255) 314 #: The node's latest capture-store reading (#1714). ``None`` is *not 315 #: measured* — an older node, or one mid-first-pass. 316 capture_store: NodeCaptureStoreReport | None = None 317 #: This node's ``repository:tag -> RepoDigest sha`` for registry-sourced 318 #: images, for the fleet image-consistency check. ⚠️ **Three-valued the 319 #: way ``capture_store`` is**: ``None`` is *did not measure this beat* — 320 #: the scan is hourly, or the daemon was unreadable — and the platform 321 #: keeps the last map; a map (even ``{}``) replaces it. Defaulting to 322 #: ``{}`` would make every beat between hourly scans clear a node's map 323 #: and hide a live divergence. 324 image_digests: dict[str, str] | None = None 325 #: The node's last build-egress sync (#1868). ``None`` is *not 326 #: reported* — an older node, one whose sync has not run yet, or one 327 #: the platform has never answered with ``untrusted_builds`` — and 328 #: keeps whatever the row last held, the keep-last rule ``version`` 329 #: follows. 330 build_egress: NodeBuildEgressReport | None = None 331 #: The node's last helper-image check (#1875). ``None`` is *not 332 #: reported* — an older node, or one whose first check has not 333 #: finished — and keeps whatever the row last held. 334 helper_images: NodeHelperImagesReport | None = None 335 #: Where this node's ingress canary answers (#1903) — the public URL a 336 #: player would use for a box here, under the canary's fixed label. 337 #: ``None`` is *not reported* (an older node) and keeps the row's value; 338 #: ``""`` is *this node runs no canary* and clears it. The platform 339 #: dials it only if it is the canary's shape (`canary_address`). 340 ingress_canary_url: str | None = Field(default=None, max_length=512)
Body for POST /nodes/heartbeat.
Node reports its live counts + resource utilisation on every beat. The percentages are 0–100; 0 is a safe default for the first beat where psutil hasn't had a prior sample to diff against.
343class NodeHeartbeatResponse(CtfyModel): 344 """Body for ``POST /nodes/heartbeat``'s reply. 345 346 The beat used to answer a bare ``{"status": "ok"}``. It carries the 347 platform's authoritative live set for this node now, because that is 348 the only thing that lets the node's own record store shrink — see 349 :func:`ctfy.server.store_reconcile.retired_records` for what went 350 wrong without it. 351 352 ⚠️ **``None`` and ``[]`` are opposite facts and the difference is the 353 whole feature.** ``None`` means *this platform did not say* — an 354 older build, or a response the node could not read — and authorises 355 nothing. ``[]`` means *nothing is live on you*, which is 356 authoritative and is exactly the case a leaked node is in. Defaulting 357 the field to ``[]`` would make every old platform look like it had 358 just retired the fleet, so the default is ``None`` and the platform 359 always fills it. 360 """ 361 362 status: str = "ok" 363 #: Instance ids the platform holds live *on this node*. Scoped to the 364 #: node on purpose: an unscoped set would make one node's records 365 #: answerable by another node's placements, and the failure mode of 366 #: getting that backwards is retiring live instances. 367 live_instance_ids: list[str] | None = None 368 #: Whether an operator has marked this node as one player-authored 369 #: code may be built on. The node cannot see the label — it lives on 370 #: the platform's `NodeState` — so it rides the beat, and the node 371 #: turns it into a firewall rule keeping a build away from other 372 #: networks' published container ports. 373 #: 374 #: ⚠️ **Three-valued for `live_instance_ids`' reason, and the third 375 #: value is the whole point.** ``None`` is *this platform did not 376 #: say* — an older build, or a body the node could not read — and 377 #: authorises nothing in either direction: the node neither installs 378 #: nor removes. ``False`` is authoritative and is what gives an 379 #: operator their firewall back when they unmark a node, so it can 380 #: never be conflated with silence. 381 untrusted_builds: bool | None = None 382 #: Match indexes (`awd_matches.network_index`) the platform holds live 383 #: on this node, so the node can reap a ``ctfy-awd-*`` tunnel whose 384 #: match is over. ``None`` / ``[]`` are the same opposite facts as 385 #: ``live_instance_ids``: ``None`` is *this platform did not say* and 386 #: reaps nothing, ``[]`` is authoritative and reaps every match 387 #: tunnel. Scoped to the node, and composed from the same live set 388 #: (dict ∪ durable claims) so a platform mid-re-adoption does not tell 389 #: a node its live match is over — the failure `live_instance_ids` 390 #: was extended to close. 391 live_match_indexes: list[int] | None = None 392 #: How long, in seconds, a challenge image may sit idle on this node 393 #: before its reaper reclaims it — the platform's DB-resolved 394 #: ``challenge_image_retention_s``. The node has no settings of its 395 #: own, so this rides the beat like ``untrusted_builds``. 396 #: 397 #: ⚠️ **Three-valued, and the third value is the whole point.** 398 #: ``None`` is *this platform did not say* — an older build, or a body 399 #: the node could not read — and the node reaps nothing. ``0`` is 400 #: authoritative and means *forever* (the shipped default), so an 401 #: operator who never sets it keeps every image; only a positive value 402 #: reaps. Defaulting to ``None`` rather than ``0`` keeps the two 403 #: opposite facts apart, but here they happen to act the same — both 404 #: leave images alone — so the danger a wrong default would cause is a 405 #: *reap*, which is why the default is the do-nothing one. 406 challenge_image_retention_s: int | None = None 407 #: How long a *finished* instance's capture directory is kept on the 408 #: node, in seconds — the platform's DB-resolved 409 #: ``instance_capture_retention_s``, pushed for 410 #: ``challenge_image_retention_s``'s reason (the node has no settings 411 #: layer). Same three values: ``None`` = did not say (reclaim 412 #: nothing), ``0`` = forever (the shipped default), positive = reap. 413 instance_capture_retention_s: int | None = None 414 #: Per-instance ceiling on a live capture's tcpdump segments, bytes — 415 #: ``instance_capture_max_bytes``. ``None`` = did not say, ``0`` = no 416 #: cap. ⚠️ Every drop it causes is recorded on disk and reported; 417 #: a cap that trims silently is the loss this setting must not be. 418 instance_capture_max_bytes: int | None = None
Body for POST /nodes/heartbeat's reply.
The beat used to answer a bare {"status": "ok"}. It carries the
platform's authoritative live set for this node now, because that is
the only thing that lets the node's own record store shrink — see
ctfy.server.store_reconcile.retired_records() for what went
wrong without it.
⚠️ None and [] are opposite facts and the difference is the
whole feature. None means this platform did not say — an
older build, or a response the node could not read — and authorises
nothing. [] means nothing is live on you, which is
authoritative and is exactly the case a leaked node is in. Defaulting
the field to [] would make every old platform look like it had
just retired the fleet, so the default is None and the platform
always fills it.
42class NodeInfo(CtfyModel): 43 id: str 44 url: str 45 display_name: str = "" 46 capacity: int = 0 47 running: int = 0 48 is_healthy: bool = True 49 last_heartbeat: datetime | None = None 50 # First successful registration. ``None`` only for rows that 51 # pre-date the field; read-path code in 52 # ``ctfy/server/routes/nodes.py`` falls back to ``last_heartbeat`` 53 # so the admin UI never shows "—" for established nodes. 54 registered_at: datetime | None = None 55 labels: dict[str, str] = Field(default_factory=dict) 56 # Latest resource sample reported on heartbeat (0–100). 57 cpu_percent: float = 0.0 58 memory_percent: float = 0.0 59 disk_percent: float = 0.0 60 # Physical RAM in MiB; 0 = not reported yet. Surfaced so the admin 61 # Nodes page can show real headroom rather than a bare percentage. 62 memory_total_mb: float = 0.0 63 # Owning vendor slug for a bring-your-own-node; "" = platform-shared. 64 owner_vendor_id: str = "" 65 # The ctfy version this node last reported. "" = a node build that 66 # predates the field. Surfaced so the admin Nodes page can show a 67 # skew against the platform's own version — the node is the one 68 # boundary shipping structured data between two processes, and a 69 # mismatch there loses fields silently rather than erroring. 70 version: str = "" 71 # What this node is actually running, as two facts `version` cannot 72 # carry. ⚠️ **`version` is semver and two nodes at the same semver 73 # are routinely different machines** — measured on this deployment, 74 # where both workers reported `0.6.8` while one ran an image three 75 # builds older and a corpus a month behind, and neither the nodes 76 # page nor the skew reporter could say so. A challenge scheduled to 77 # the stale one then failed with `Challenge not found on this node`, 78 # intermittently, because placement is what decided which corpus 79 # answered. 80 # 81 # ⚠️ `""` is *unreadable*, never a mismatch: a node build predating 82 # the field, an image without `CTFY_GIT_COMMIT`, or a corpus that is 83 # not a working tree. `corpus_commit_of` states the same rule for 84 # the second one, and every reader here honours it by staying quiet. 85 git_commit: str = "" 86 corpus_commit: str = "" 87 # What this node last reported it holds, as ``repository:tag -> 88 # RepoDigest sha``, for registry-sourced images only. The platform 89 # compares these across nodes (`core/image_consistency.py`) so a tag 90 # resolving to different content on different workers is surfaced. Empty 91 # for a node that predates the field or has not yet reported; the 92 # comparison ignores an empty map exactly as it ignores an empty digest. 93 image_digests: dict[str, str] = Field(default_factory=dict) 94 # Whether this node's build-egress rules are what the platform asked 95 # for (#1868). ⚠️ ``""`` is *not reported* — a node build predating 96 # the field, or one the platform has not yet answered — and never 97 # *in force*; the nodes page renders it as unknown. The reason is the 98 # node's own words for a failure, empty otherwise. 99 build_egress_status: BuildEgressStatus | Literal[""] = "" 100 build_egress_reason: str = "" 101 # Whether every helper image this node is configured with is on its 102 # daemon (#1875). ⚠️ ``""`` is *not reported* and never *present*; 103 # the reason names each missing ref and why it could not be pulled. 104 helper_images_status: HelperImagesStatus | Literal[""] = "" 105 helper_images_reason: str = "" 106 # An operator's cordon (#1900): this node takes no new instance, build, 107 # pull or patch judge, and keeps running what it already has. 108 cordoned: bool = False 109 # Whether the node carries the ``heavy_estates`` label in its closed 110 # truthy set (#1900) — large estates are placed here first. Projected 111 # by the platform (`scheduling.node_hosts_heavy_estates`) so no client 112 # re-implements which spellings of the label count: the raw label 113 # still rides ``labels``, and ``heavy_estates=false`` must read *no*. 114 heavy_estates: bool = False 115 # Where this node's ingress canary answers (#1903), as the node 116 # reported it. ``""`` advertises none. 117 ingress_canary_url: str = "" 118 # The platform's last reading of this node's ingress, dialled from 119 # outside the node (`ctfy.server.ingress_probe`). ⚠️ ``""`` is *not 120 # probed* — no canary advertised, or not reached yet — and never 121 # *ok*; ``not_applicable`` is a pool that routes no player (freeze, 122 # quarantine), projected from the one pool rule, never stored. 123 ingress_status: IngressStatus | Literal[""] = "" 124 ingress_reason: str = "" 125 # The wildcard certificate's ``notAfter`` as served to the probe; 126 # ``None`` over plain http or before a reading. 127 ingress_cert_not_after: datetime | None = None 128 ingress_checked_at: datetime | None = None
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
516class NodeInviteInfo(CtfyModel): 517 """Admin-facing summary (``GET /nodes/invites``). No token fields.""" 518 519 id: str 520 created_at: datetime | None = None 521 expires_at: datetime | None = None 522 status: NodeInviteStatus 523 consumed_by_node_id: str = "" 524 owner_vendor_id: str = "" # vendor binding, if any ("" = shared)
Admin-facing summary (GET /nodes/invites). No token fields.
157class NodePatch(CtfyModel): 158 """Body for ``PATCH /admin/nodes/{node_id}``. 159 160 Fields are optional — absent fields keep their current value. 161 display_name is the common case; labels allow adding/replacing the 162 full dict (partial label edits need a second round-trip). 163 ``owner_vendor_id`` re-assigns (or clears, with ``""``) the node's 164 vendor binding — an admin correction path complementing the 165 invite-time binding. 166 """ 167 168 display_name: str | None = None 169 labels: dict[str, str] | None = None 170 owner_vendor_id: str | None = None 171 #: ``True`` cordons the node (#1900): it takes nothing new and keeps 172 #: running what it has. ``False`` uncordons it. 173 cordoned: bool | None = None
Body for PATCH /admin/nodes/{node_id}.
Fields are optional — absent fields keep their current value.
display_name is the common case; labels allow adding/replacing the
full dict (partial label edits need a second round-trip).
owner_vendor_id re-assigns (or clears, with "") the node's
vendor binding — an admin correction path complementing the
invite-time binding.
1069class NodeRangePush(CtfyModel): 1070 """Platform → node: build this generated range here. 1071 1072 ⚠️ **The scenario travels, not the estate.** A rendered range is 1073 70-odd files; this is a JSON object, and the node has everything it 1074 needs to compile it — the corpus is mounted there too and the 1075 compiler ships inside the corpus. Pushing files over the node API is 1076 the shape ``gauntlet_verify`` argues against, for the same reason. 1077 1078 ⚠️ **Which is why the corpus commit travels with it.** One scenario 1079 compiled against two corpus commits is two *different* estates 1080 wearing one id: a node serving different services and different 1081 milestones under the id a competition names, with nothing on any 1082 screen saying so. `ctfy.server.node_ranges.apply_range_push` refuses 1083 on a mismatch. 1084 """ 1085 1086 range_id: str 1087 #: The stored knobs, exactly as the platform holds them. 1088 scenario: dict[str, Any] = Field(default_factory=dict) 1089 #: The corpus commit the platform compiled this range from. ``""`` 1090 #: means the platform could not read one (no git binary, a corpus 1091 #: that is a tarball) — **not** "any commit will do" and never a 1092 #: mismatch, so the node proceeds and reports its own instead. 1093 expect_corpus_commit: str = ""
Platform → node: build this generated range here.
⚠️ The scenario travels, not the estate. A rendered range is
70-odd files; this is a JSON object, and the node has everything it
needs to compile it — the corpus is mounted there too and the
compiler ships inside the corpus. Pushing files over the node API is
the shape gauntlet_verify argues against, for the same reason.
⚠️ Which is why the corpus commit travels with it. One scenario
compiled against two corpus commits is two different estates
wearing one id: a node serving different services and different
milestones under the id a competition names, with nothing on any
screen saying so. ctfy.server.node_ranges.apply_range_push refuses
on a mismatch.
1096class NodeRangeResult(CtfyModel): 1097 """Node → platform: what this node did with a pushed range. 1098 1099 ⚠️ ``corpus_commit`` rather than a bare ``ok``: two nodes can both 1100 succeed and hold different estates, and only the commit says so. 1101 """ 1102 1103 range_id: str = "" 1104 ok: bool = False 1105 #: What this node compiled from. Empty when it could not read one. 1106 corpus_commit: str = "" 1107 #: Why not. Empty on success — never a generic failure, because the 1108 #: repairs differ: a corpus mismatch is fixed on the node's host, a 1109 #: compiler refusal on the scenario. 1110 error: str = ""
Node → platform: what this node did with a pushed range.
⚠️ corpus_commit rather than a bare ok: two nodes can both
succeed and hold different estates, and only the commit says so.
1310class NodeRangeSyncLine(CtfyModel): 1311 """One node's outcome in a fleet-wide range push. 1312 1313 ⚠️ Three states, not two: ``ok``, refused (``error`` from the node), 1314 and unreachable (``error`` from the transport). A boolean would 1315 render the last two identically, and their repairs are on different 1316 machines. 1317 """ 1318 1319 node_id: str = "" 1320 ok: bool = False 1321 corpus_commit: str = "" 1322 error: str = ""
1209class NodeRangeVerifyPush(CtfyModel): 1210 """Platform → node: build this scenario, solve it, take it down. 1211 1212 ⚠️ **The scenario travels, not the estate** — the same decision 1213 :class:`NodeRangePush` records, and for the same reason: the corpus 1214 is mounted on the node and the compiler ships inside it. A first 1215 attempt tarred the rendered estate and posted it; that also loses 1216 ``expect_corpus_commit``, which is the whole protection. 1217 1218 ⚠️ **This is a question, not a publication.** Unlike a range *push* 1219 there is no ``range_id``: the node renders into scratch and throws it 1220 away, so a verification that fails cannot leave the estate it just 1221 disproved sitting in the catalog. 1222 """ 1223 1224 #: The platform's id for this run, and the handle a cancel names. 1225 #: 1226 #: ⚠️ **Minted by the platform, not the node.** The operator presses 1227 #: cancel on the platform, which must be able to name the run before 1228 #: the node has answered anything — a node-minted id would only exist 1229 #: once the run was over. 1230 run_id: str = "" 1231 #: The studio knobs, exactly as the platform holds them. 1232 scenario: dict[str, Any] = Field(default_factory=dict) 1233 #: The corpus commit the platform previewed against. ``""`` means the 1234 #: platform could not read one — **not** "any commit will do" — so 1235 #: the node proceeds and reports its own, as ``NodeRangePush`` does. 1236 expect_corpus_commit: str = "" 1237 compose_timeout_s: int = 900 1238 solve_timeout_s: int = 600
Platform → node: build this scenario, solve it, take it down.
⚠️ The scenario travels, not the estate — the same decision
NodeRangePush records, and for the same reason: the corpus
is mounted on the node and the compiler ships inside it. A first
attempt tarred the rendered estate and posted it; that also loses
expect_corpus_commit, which is the whole protection.
⚠️ This is a question, not a publication. Unlike a range push
there is no range_id: the node renders into scratch and throws it
away, so a verification that fails cannot leave the estate it just
disproved sitting in the catalog.
1241class NodeRangeVerifyResult(CtfyModel): 1242 """Node → platform: what happened, for the platform to judge. 1243 1244 ⚠️ **Reports; does not judge.** There is no ``passed`` here on 1245 purpose — that compares captures against the milestone count, and a 1246 node deciding it would be the second place that does, which is how 1247 the board and the counters came to disagree elsewhere in this 1248 codebase. 1249 """ 1250 1251 #: What this node compiled from. Empty when it could not read one. 1252 corpus_commit: str = "" 1253 #: A refusal before any work: corpus mismatch, or a compiler that 1254 #: would not build these knobs. 1255 error: str = "" 1256 #: ``plan_verification``'s refusal — the estate is too big to build 1257 #: here. Distinct from ``error``: the scenario is fine, this node is 1258 #: not the place. 1259 refusal: str = "" 1260 services: int = 0 1261 milestones: int = 0 1262 memory_mb: int = 0 1263 up_exit_code: int | None = None 1264 #: Tail of the failing step's output; a task row is not a log store. 1265 up_output: str = "" 1266 #: The reference solution's stdout, which is what capture counting 1267 #: reads. Empty when the build never got that far. 1268 solve_stdout: str = "" 1269 solve_exit_code: int | None = None 1270 #: Which step ran out of its budget, if one did. 1271 timed_out: str = "" 1272 #: Set when the operator cancelled, naming where it landed. 1273 #: 1274 #: ⚠️ **Its own field, not an ``error``.** A cancelled run is not a 1275 #: failed one: the range is not disproved, the operator changed their 1276 #: mind, and rendering that as a failure would make the studio report 1277 #: a broken scenario every time somebody pressed the button. 1278 cancelled: str = "" 1279 #: ⚠️ Whether the node really took the estate down. The platform has 1280 #: no other way to see it, and a range left running is the failure 1281 #: this path is most likely to produce — the node tears down in its 1282 #: own ``finally`` so a platform that disconnected still leaves a 1283 #: clean host, but then nobody would know it had happened. 1284 torn_down: bool = False
Node → platform: what happened, for the platform to judge.
⚠️ Reports; does not judge. There is no passed here on
purpose — that compares captures against the milestone count, and a
node deciding it would be the second place that does, which is how
the board and the counters came to disagree elsewhere in this
codebase.
35class NodeRegister(CtfyModel): 36 url: str # e.g. "http://node1:8100" 37 display_name: str # required; operators pick a human-readable label 38 capacity: int = DEFAULT_NODE_CAPACITY 39 labels: dict[str, str] = Field(default_factory=dict) # optional metadata
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
470class NodeRegisterResponse(NodeInfo): 471 """Registration response carries the node's bearer token (plaintext). 472 473 Returned exactly once, at registration. The same token is used in 474 both directions: 475 476 * **node→platform** (heartbeat, deregister) — node presents it as 477 the Bearer; platform looks the row up by comparing plaintexts. 478 * **platform→node** (start/stop/status) — platform presents the 479 same plaintext on every call; node verifies against its in-memory 480 copy. 481 482 Re-registration rotates the token. The node never persists it on 483 disk; "restart = re-register = fresh credential". 484 """ 485 486 token: str = ""
Registration response carries the node's bearer token (plaintext).
Returned exactly once, at registration. The same token is used in both directions:
- node→platform (heartbeat, deregister) — node presents it as the Bearer; platform looks the row up by comparing plaintexts.
- platform→node (start/stop/status) — platform presents the same plaintext on every call; node verifies against its in-memory copy.
Re-registration rotates the token. The node never persists it on disk; "restart = re-register = fresh credential".
445class NodeRegistryTicket(CtfyModel): 446 """Reply for ``POST /nodes/registry-ticket``. 447 448 ⚠️ **``ticket == ""`` is a refusal, never an error**, and ``reason`` 449 says which: this deployment runs no registry, it cannot say which 450 account its images live under, or — the one that is a decision — this 451 node runs player-authored code. That last node is one the operator has 452 declared may be taken over (`scheduling.node_runs`), so a credential it 453 can obtain is a credential its tenants can obtain; it gets none, and 454 its builds run with an empty registry config. The node then falls back 455 to its ambient docker config, which is exactly the behaviour before 456 this existed. 457 """ 458 459 #: The password half of a docker login for ``CTFY_REGISTRY_DOMAIN``, 460 #: or ``""``. 461 ticket: str = "" 462 #: Seconds the ticket lives; ``0`` with no ticket. 463 expires_in: int = 0 464 #: The account whose repositories it may pull, for the node's log. 465 namespace: str = "" 466 #: Why there is no ticket; ``""`` when there is one. 467 reason: str = ""
Reply for POST /nodes/registry-ticket.
⚠️ ticket == "" is a refusal, never an error, and reason
says which: this deployment runs no registry, it cannot say which
account its images live under, or — the one that is a decision — this
node runs player-authored code. That last node is one the operator has
declared may be taken over (scheduling.node_runs), so a credential it
can obtain is a credential its tenants can obtain; it gets none, and
its builds run with an empty registry config. The node then falls back
to its ambient docker config, which is exactly the behaviour before
this existed.
421class NodeRegistryTicketRequest(CtfyModel): 422 """Body for ``POST /nodes/registry-ticket`` (#1820). 423 424 A node about to build or start one of the platform's challenges asks 425 for a short-lived credential to pull the platform's own images (the 426 AWD gamebox bases, the corpus's pinned mirrors) — one per operation. 427 """ 428 429 #: The asking node's own id; a node token may only ask for itself. 430 id: str 431 #: How long the operation it is for may run, in seconds. Docker reads 432 #: its credential once, at the start, and presents it again for every 433 #: later pull, so the ticket must outlive the operation. Clamped by 434 #: the platform to ``NAMESPACE_PULL_TICKET_MAX_TTL_S``. 435 ttl_s: int = Field(default=1800, ge=1) 436 #: ``""`` asks for the namespace ticket a challenge build or start 437 #: uses. A repository (``<account>/ctfy-edge-dns``, no host, no tag) 438 #: asks for a pull ticket on **that repository alone**, which the 439 #: platform grants only for one of its own helper images (#1875) — 440 #: and grants to every pool, because a helper is the same image the 441 #: node already runs and is no secret of anybody's. 442 repository: str = Field(default="", max_length=255)
Body for POST /nodes/registry-ticket (#1820).
A node about to build or start one of the platform's challenges asks for a short-lived credential to pull the platform's own images (the AWD gamebox bases, the corpus's pinned mirrors) — one per operation.
28class NotificationCategoryInfo(CtfyModel): 29 id: str 30 title: str 31 description: str 32 #: Account & security. Not switchable; the API refuses to store a cell. 33 forced: bool 34 #: ``transactional`` | ``marketing``. A marketing category is off 35 #: until the recipient explicitly turns it on, and a recorded 36 #: opt-out on one cannot be reverted away — so the UI must not 37 #: offer "revert to default" there, which would imply reverting 38 #: could switch it back on. 39 policy: str = "transactional" 40 channels: dict[str, NotificationChannelCell]
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
14class NotificationChannelCell(CtfyModel): 15 """One (category, channel) cell, as the settings UI needs it. 16 17 Three values rather than one because storage is sparse: ``effective`` 18 is what happens now, ``default`` is what reverting would restore, and 19 ``explicit`` says whether the user chose this or is inheriting it — 20 which is also whether a "revert" control should appear at all. 21 """ 22 23 effective: bool 24 explicit: bool 25 default: bool
One (category, channel) cell, as the settings UI needs it.
Three values rather than one because storage is sparse: effective
is what happens now, default is what reverting would restore, and
explicit says whether the user chose this or is inheriting it —
which is also whether a "revert" control should appear at all.
47class NotificationPreferenceUpdate(CtfyModel): 48 """PATCH body for one cell.""" 49 50 category: str 51 channel: str = "email" 52 enabled: bool
PATCH body for one cell.
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.
27class OAuthProviderInfo(CtfyModel): 28 name: OAuthProviderName 29 enabled: bool 30 authorize_path: str # relative, e.g. "/api/v1/auth/login/github" 31 # Operator-supplied button label. Empty for the built-in providers, 32 # whose names the client already translates ("Sign in with GitHub"). 33 # A generic OIDC provider has no name the client could know — the 34 # only useful label is the one the operator chose ("清华大学统一身份 35 # 认证"), so it travels on the wire rather than living in a bundle. 36 display_name: str = "" 37 # A link-only provider (CARSI) is bound from a signed-in account and is 38 # never a way in. Only ``ProvidersResponse.link_providers`` lists one; 39 # ``providers`` (the sign-in buttons) never does. 40 link_only: bool = False 41 # Where the provider *is* usable when this host does not carry it: the 42 # origin of the host whose credentials exist. ``""`` = usable here (or 43 # nowhere). A browser session is per origin, so the primary can only 44 # point there, not bind on its behalf. 45 available_at: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
297class OnboardingIntentRequest(CtfyModel): 298 """Body for ``POST /me/onboarding`` — what this account came to do. 299 300 ⚠️ **Its own request model, deliberately not a field on 301 :class:`ProfilePatchRequest`.** A profile is what a visitor reads 302 off somebody's page; this is an answer given to the platform, and 303 the two have different audiences, different write paths and 304 different lifetimes. Folding it in would also put it inside the 305 profile-visibility machinery, where every key is a thing somebody 306 can choose to publish. 307 308 ⚠️ The type is the enum, so an unrecognised value is a **422 and no 309 write**. The welcome wizard branches on this string; a stored 310 ``"agent"`` — a plausible typo for ``byo`` — matches no branch and 311 renders a blank card where the guidance was, with nothing reporting 312 it. ``""`` is accepted so a client can put an account back to 313 "never asked". 314 """ 315 316 intent: OnboardingIntent
Body for POST /me/onboarding — what this account came to do.
⚠️ Its own request model, deliberately not a field on
ProfilePatchRequest. A profile is what a visitor reads
off somebody's page; this is an answer given to the platform, and
the two have different audiences, different write paths and
different lifetimes. Folding it in would also put it inside the
profile-visibility machinery, where every key is a thing somebody
can choose to publish.
⚠️ The type is the enum, so an unrecognised value is a 422 and no
write. The welcome wizard branches on this string; a stored
"agent" — a plausible typo for byo — matches no branch and
renders a blank card where the guidance was, with nothing reporting
it. "" is accepted so a client can put an account back to
"never asked".
48class PasswordAuthInfo(CtfyModel): 49 """Gating flag for the email+password sign-in form on the login page.""" 50 51 enabled: bool = False
Gating flag for the email+password sign-in form on the login page.
1550class PatchStepInfo(CtfyModel): 1551 """One stage of a verification, as the node judged it — organiser-only. 1552 1553 The node runs build → boot → each checker action → reference exploit 1554 and returns a result per stage; the platform used to keep only the 1555 single player-facing sentence, so an `inconclusive` was three 1556 different repairs at once (issue #1692). These are on the admin 1557 detail read alone: a checker's ``detail`` can describe the 1558 challenge's internals, which the defender must not see. 1559 """ 1560 1561 name: str = "" 1562 ok: bool = False 1563 detail: str = ""
One stage of a verification, as the node judged it — organiser-only.
The node runs build → boot → each checker action → reference exploit
and returns a result per stage; the platform used to keep only the
single player-facing sentence, so an inconclusive was three
different repairs at once (issue #1692). These are on the admin
detail read alone: a checker's detail can describe the
challenge's internals, which the defender must not see.
1415class PatchSubmissionCreate(CtfyModel): 1416 """An AWD+ defence submission, addressed one of two ways. 1417 1418 ``instance_id`` is the handle when a box is running, exactly as on 1419 :class:`SubmissionCreate`: the instance row carries the challenge, 1420 the team and the competition, so the caller repeats none of them. 1421 1422 ⚠️ **But a defence submission does not need a box, and requiring one 1423 made the format's natural order unplayable.** Judging builds from 1424 the pristine tree plus the submitted bytes and never touches the 1425 instance, ``GET /challenges/{id}/defence/source`` is already 1426 challenge-scoped for exactly this reason, and upsolve, virtual 1427 participation and a post-event fix all mean patching with nothing 1428 running. So ``challenge_id`` (plus ``competition_id``, which scopes 1429 which team is credited) addresses it directly. 1430 1431 Naming neither is refused rather than guessed at: falling back to 1432 "the caller's only instance" would silently judge a challenge they 1433 did not name. Naming an instance *and* a different challenge is 1434 refused for the same reason. 1435 1436 **This is not a weaker authorisation than the instance form.** There 1437 is no team on the wire either way — the credited team is resolved 1438 from the caller's own membership, as ``GET /patches`` already does — 1439 so the challenge form has no id to spoof at all. 1440 """ 1441 1442 instance_id: str = "" 1443 #: The challenge to defend, when no box is running. Ignored (but 1444 #: cross-checked) when ``instance_id`` is given. 1445 challenge_id: str = "" 1446 #: Which competition credits the patch. Empty means the caller's 1447 #: unscoped team, the same convention ``GET /patches`` follows. 1448 competition_id: str = "" 1449 #: Where the patched bytes come from. 1450 #: 1451 #: ``upload`` carries them in ``files``. ``live`` reads them out of 1452 #: the player's own running box — they SSH in, edit, and submit with 1453 #: no payload at all, which is how an AWD player actually works. 1454 #: 1455 #: Explicit rather than inferred from an empty ``files``: an empty 1456 #: map is also what a client bug produces, and silently switching 1457 #: channel on a bug would grade something the player never meant to 1458 #: send. 1459 source: Literal["upload", "live"] = "upload" 1460 #: ``{challenge-relative path: base64(bytes)}``. Base64 because a 1461 #: patch target may legitimately be binary, and JSON has no way to 1462 #: carry those bytes intact otherwise. Must be empty when 1463 #: ``source="live"``. 1464 files: dict[str, str] = Field(default_factory=dict)
An AWD+ defence submission, addressed one of two ways.
instance_id is the handle when a box is running, exactly as on
SubmissionCreate: the instance row carries the challenge,
the team and the competition, so the caller repeats none of them.
⚠️ But a defence submission does not need a box, and requiring one
made the format's natural order unplayable. Judging builds from
the pristine tree plus the submitted bytes and never touches the
instance, GET /challenges/{id}/defence/source is already
challenge-scoped for exactly this reason, and upsolve, virtual
participation and a post-event fix all mean patching with nothing
running. So challenge_id (plus competition_id, which scopes
which team is credited) addresses it directly.
Naming neither is refused rather than guessed at: falling back to "the caller's only instance" would silently judge a challenge they did not name. Naming an instance and a different challenge is refused for the same reason.
This is not a weaker authorisation than the instance form. There
is no team on the wire either way — the credited team is resolved
from the caller's own membership, as GET /patches already does —
so the challenge form has no id to spoof at all.
1501class PatchSubmissionInfo(CtfyModel): 1502 """A defence submission's status — deliberately without its code. 1503 1504 The stored row keeps the submitted bytes so a verdict can be 1505 re-derived after a checker fix, but a *status* view is polled, and 1506 shipping every patch body on every poll is bulk nobody asked for. 1507 """ 1508 1509 id: str 1510 challenge_id: str 1511 competition_id: str 1512 submitted_at: datetime | None = None 1513 #: The paths this submission replaced. 1514 files: list[str] = Field(default_factory=list) 1515 #: ⚠️ The stored column is already `PatchSubmissionStatus`; this 1516 #: copy was a bare `str`, so the client asserted nothing about the 1517 #: value it branches on. 1518 status: PatchSubmissionStatus = "pending" 1519 #: Empty until judged, and empty forever on a submission the 1520 #: platform failed to judge — our outage is not the player's loss. 1521 #: ⚠️ Bare `str` until now, and `DefenceCard` compares it against 1522 #: literals — measured, `row.verdict === "not_fixxed"` compiled with 1523 #: **zero** `tsc` errors. Typed, that typo is a build failure. 1524 verdict: Verdict | Literal[""] = "" 1525 detail: str = "" 1526 judged_at: datetime | None = None
A defence submission's status — deliberately without its code.
The stored row keeps the submitted bytes so a verdict can be re-derived after a checker fix, but a status view is polled, and shipping every patch body on every poll is bulk nobody asked for.
469class PlatformSettingInfo(CtfyModel): 470 """One row from ``GET /admin/platform-settings``. 471 472 ``value`` is the live resolved value (DB > env > default); the 473 other two fields show what env and the built-in default would 474 resolve to in isolation, so the UI can render a tooltip 475 explaining what a ``DELETE`` would land on. ``source`` is the 476 tier the current value came from. 477 """ 478 479 key: str = "" 480 type: str = "" 481 description: str = "" 482 value: Any = None 483 source: SettingSource | Literal[""] = "" 484 env_value: Any = None 485 default_value: Any = None 486 #: Whether the resolved ``value`` is actually being applied. 487 #: 488 #: ⚠️ **A setting can resolve ``True`` and do nothing**, and this 489 #: page is where an operator forms the belief that it is running. 490 #: ``email_verification_required`` is the live case: 491 #: ``enforcement_enabled`` is ``wanted AND the deployment can send 492 #: mail``, because the only self-service way to satisfy the gate is 493 #: a mailed code — so on a deployment with no mail provider the row 494 #: reads ``true`` while every unproved account writes freely. That 495 #: is this repository's second failure shape at operator scale: a 496 #: thing that reports healthy while doing nothing, stably and 497 #: invisibly. 498 #: 499 #: ⚠️ Defaults **True** — the withheld value must never be a new 500 #: claim, and "not in force" is a claim. An older server that omits 501 #: the field says nothing rather than accusing every setting of 502 #: being inert. 503 in_force: bool = True 504 #: Why the value is not in force, as a stable code the admin page 505 #: translates; ``""`` when it is. A code rather than prose because 506 #: the reason has to be readable in both locales, and rendering it 507 #: server-side would put copy on a route that carries none. 508 inert_reason: str = ""
One row from GET /admin/platform-settings.
value is the live resolved value (DB > env > default); the
other two fields show what env and the built-in default would
resolve to in isolation, so the UI can render a tooltip
explaining what a DELETE would land on. source is the
tier the current value came from.
511class PlatformSettingPatch(CtfyModel): 512 """Body of ``PATCH /admin/platform-settings/{key}``. 513 514 ``value`` is JSON-typed; the resolver's per-key validator decides 515 whether it's accepted. 516 """ 517 518 value: Any = None
Body of PATCH /admin/platform-settings/{key}.
value is JSON-typed; the resolver's per-key validator decides
whether it's accepted.
637class PlayerShellCreate(CtfyModel): 638 """Ask for a shell into your own AWD+ box. 639 640 Note what is *absent*: no container. The admin shell takes one 641 because a super-admin legitimately picks among an instance's 642 containers; accepting one here would hand the caller the choice the 643 endpoint exists to deny them. The platform resolves the target from 644 the challenge's own ``patch.live.service``. 645 """ 646 647 #: ``bash`` or ``sh``. A tiny allow-list rather than free text — 648 #: the value becomes the exec'd command on the node. 649 shell: str = "bash"
Ask for a shell into your own AWD+ box.
Note what is absent: no container. The admin shell takes one
because a super-admin legitimately picks among an instance's
containers; accepting one here would hand the caller the choice the
endpoint exists to deny them. The platform resolves the target from
the challenge's own patch.live.service.
721class PlayerShellTicket(CtfyModel): 722 """A single-use ticket for one shell session. 723 724 Two-step because a browser cannot attach an ``Authorization`` header 725 to a WebSocket upgrade. Worth keeping for terminal clients too: it 726 means the long-lived bearer never rides in a URL. 727 """ 728 729 session_id: str 730 #: Burns on first WebSocket use and is never returned again. 731 session_token: str 732 #: Platform-relative; the client makes it absolute against its own 733 #: base URL, since only the client knows the scheme it reached us on. 734 ws_url: str 735 #: The resolved compose container. Empty for the browser, which 736 #: never needs it; the bastion does, because it opens the node 737 #: WebSocket itself rather than going through the platform page. 738 container: str = "" 739 #: Seconds the unburned ticket stays valid. 740 expires_in: int 741 #: The compose service the session will land in. Echoed back so a 742 #: player knows which container they are in without reading the 743 #: challenge's metadata — the platform resolved it, so it says so. 744 service: str
A single-use ticket for one shell session.
Two-step because a browser cannot attach an Authorization header
to a WebSocket upgrade. Worth keeping for terminal clients too: it
means the long-lived bearer never rides in a URL.
319class ProfilePatchRequest(CtfyModel): 320 """Body for ``PATCH /me/profile``. Every field is optional so the 321 UI can send only the diff; the route only writes the fields that 322 were present in the JSON. ``None`` means "clear this field".""" 323 324 # ``Field(default=...)`` with a non-None sentinel is awkward in 325 # pydantic; we instead declare each field as Optional + default-None 326 # and rely on the request's raw dict (via ``model_fields_set``) to 327 # know which keys were actually present. 328 bio: str | None = Field(default=None, max_length=280) 329 country: str | None = None 330 website_url: WebUrl | None = Field(default=None, max_length=200) 331 timezone: str | None = Field(default=None, max_length=80) 332 # Values are *handles*, not URLs — ``{"github": "octocat"}``. The 333 # client owns href construction from a fixed per-provider template 334 # (``https://github.com/<handle>``), and the two providers that do 335 # accept a free-form value run it through ``normalizeUrl`` / 336 # ``mastodonUrl``, both of which only ever emit ``https://…``. So a 337 # scheme constraint here would be the wrong shape: it would reject 338 # every legitimate handle while defending against nothing. What was 339 # actually missing is a size bound. 340 social_links: dict[str, str] | None = None 341 342 @field_validator("social_links") 343 @classmethod 344 def _bound_social_links(cls, v: dict[str, str] | None) -> dict[str, str] | None: 345 if v is None: 346 return None 347 if len(v) > 10: 348 raise ValueError("social_links may contain at most 10 entries") 349 if any(len(handle) > 200 for handle in v.values()): 350 raise ValueError("each social link must be at most 200 characters") 351 return v
Body for PATCH /me/profile. Every field is optional so the
UI can send only the diff; the route only writes the fields that
were present in the JSON. None means "clear this field".
478class ProfileStats(CtfyModel): 479 """Aggregate per-team analytics surfaced on the public profile. 480 481 Every section is independently visibility-gated; non-owner viewers 482 see ``[]`` for a section the team has marked private. Owner and 483 admin always see the full payload.""" 484 485 calendar: list[CalendarBucket] = Field(default_factory=list) 486 by_tag: list[TagStat] = Field(default_factory=list) 487 by_difficulty: list[DifficultyStat] = Field(default_factory=list) 488 solve_trend: list[TrendPoint] = Field(default_factory=list)
Aggregate per-team analytics surfaced on the public profile.
Every section is independently visibility-gated; non-owner viewers
see [] for a section the team has marked private. Owner and
admin always see the full payload.
66class ProvidersResponse(CtfyModel): 67 # The sign-in buttons: link-only providers are never here. 68 providers: list[OAuthProviderInfo] = Field(default_factory=list) 69 # What an account may bind -- the same table as ``providers``, read 70 # without the link-only filter (and with a link-only provider this host 71 # does not carry shown as ``available_at`` the host that does). 72 link_providers: list[OAuthProviderInfo] = Field(default_factory=list) 73 password_auth: PasswordAuthInfo = Field(default_factory=PasswordAuthInfo) 74 email_code_auth: EmailCodeAuthInfo = Field(default_factory=EmailCodeAuthInfo)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
1444class PublicTranscriptBlock(CtfyModel): 1445 """One block of an agent's own turn, scrubbed (B2). 1446 1447 ⚠️ **Only three kinds ever reach the wire** — ``text`` (assistant 1448 prose), ``thinking`` (its reasoning), ``tool_use`` (a call it made). 1449 The server's whitelist (``core.agent_transcript``) never emits a 1450 system prompt, a tool *result* or anything else, so a fourth kind 1451 here would be a bug, not a new carrier. ``name`` is the tool for 1452 ``tool_use`` and empty otherwise; ``text`` is the prose, or the 1453 scrubbed ``key=value`` arguments for ``tool_use``. 1454 """ 1455 1456 kind: str = "" 1457 text: str = "" 1458 name: str = ""
One block of an agent's own turn, scrubbed (B2).
⚠️ Only three kinds ever reach the wire — text (assistant
prose), thinking (its reasoning), tool_use (a call it made).
The server's whitelist (core.agent_transcript) never emits a
system prompt, a tool result or anything else, so a fourth kind
here would be a bug, not a new carrier. name is the tool for
tool_use and empty otherwise; text is the prose, or the
scrubbed key=value arguments for tool_use.
1467class PublicTranscriptResponse(CtfyModel): 1468 """An entered agent's public, scrubbed conversation (B2). 1469 1470 ⚠️ **The agent's own turns only.** ``had_system`` / ``had_tool_result`` 1471 say a system prompt / a tool result was present and **withheld**, so 1472 the reader sees *that* content was removed rather than a gap that 1473 reads as the agent having said nothing. The bodies themselves never 1474 travel — see ``core.agent_transcript``'s whitelist. 1475 """ 1476 1477 call_id: str = "" 1478 team_id: str = "" 1479 team_name: str = "" 1480 model: str = "" 1481 at_ts: float = 0.0 1482 #: A body to read at all — retention may have reclaimed it. 1483 available: bool = False 1484 had_system: bool = False 1485 had_tool_result: bool = False 1486 turns: list[PublicTranscriptTurn] = Field(default_factory=list)
An entered agent's public, scrubbed conversation (B2).
⚠️ The agent's own turns only. had_system / had_tool_result
say a system prompt / a tool result was present and withheld, so
the reader sees that content was removed rather than a gap that
reads as the agent having said nothing. The bodies themselves never
travel — see core.agent_transcript's whitelist.
1461class PublicTranscriptTurn(CtfyModel): 1462 """One assistant turn — a run of blocks the agent produced together.""" 1463 1464 blocks: list[PublicTranscriptBlock] = Field(default_factory=list)
One assistant turn — a run of blocks the agent produced together.
970class QaChallengeView(CtfyModel): 971 """One QA challenge as seen by a team on the quiz surface. 972 973 Carries the full question / choices / description data alongside 974 per-team solve and attempt state, so the frontend can render the 975 quiz list with progress badges in a single round-trip. 976 """ 977 978 id: str 979 name: str 980 # Provenance bucket prefix — ``SECQA`` / ``MMLU-CS`` / ``CTI-MCQ`` 981 # / etc — derived from the challenge id (everything before the 982 # trailing numeric suffix). Lets the frontend group / filter 983 # without splitting the id again client-side. 984 bucket: str 985 description: str 986 difficulty: str = "" 987 tags: list[str] = Field(default_factory=list) 988 questions: list[QuestionPublicInfo] = Field(default_factory=list) 989 # Question ids the calling team has already captured. 990 solved_question_ids: list[str] = Field(default_factory=list) 991 # Question ids the team has submitted at least one wrong answer 992 # against but never solved. Drives the "Try again" amber badge. 993 attempted_wrong_question_ids: list[str] = Field(default_factory=list)
One QA challenge as seen by a team on the quiz surface.
Carries the full question / choices / description data alongside per-team solve and attempt state, so the frontend can render the quiz list with progress badges in a single round-trip.
953class QaSubmissionCreate(CtfyModel): 954 """Request body for ``POST /qa/submissions`` — the instance-free 955 submission path for pure question-answer challenges. 956 957 No ``instance_id``: QA challenges have no Docker infra to launch, 958 so there's no per-team instance row to read context off. The 959 caller passes ``challenge_id`` + ``competition_id`` explicitly, 960 and the server resolves the submitter's team for the named 961 competition via the standard auth helper. 962 """ 963 964 challenge_id: str 965 competition_id: str 966 question_id: str = "answer" 967 answer: str | list[str]
Request body for POST /qa/submissions — the instance-free
submission path for pure question-answer challenges.
No instance_id: QA challenges have no Docker infra to launch,
so there's no per-team instance row to read context off. The
caller passes challenge_id + competition_id explicitly,
and the server resolves the submitter's team for the named
competition via the standard auth helper.
855class QuestionAttemptResetInfo(CtfyModel): 856 """Response from the admin reset endpoint. Echoes the new baseline 857 row so the UI can update its local state without re-fetching the 858 instance. 859 """ 860 861 team_id: str 862 challenge_id: str 863 question_id: str 864 reset_at: datetime 865 reset_by_user_id: str 866 reason: str
Response from the admin reset endpoint. Echoes the new baseline row so the UI can update its local state without re-fetching the instance.
544class QuestionAttemptResetRequest(CtfyModel): 545 """Request body for ``POST /admin/teams/.../reset-attempts``. 546 547 ``reason`` is free-form audit metadata recorded onto the activity 548 log row; capped at 500 chars so it round-trips through the SSE 549 payload without bloating the wire frame. 550 """ 551 552 reason: str = Field(default="", max_length=500)
Request body for POST /admin/teams/.../reset-attempts.
reason is free-form audit metadata recorded onto the activity
log row; capped at 500 chars so it round-trips through the SSE
payload without bloating the wire frame.
1532class QuestionCoverageRow(CtfyModel): 1533 """Where one declared question ended one run. 1534 1535 ⚠️ The prompt travels because the reader is deciding what to do next, 1536 and ``dcsync_domain_admin`` is a slug. A table of slugs is a table 1537 nobody triages — ``AdminHarnessRow``'s finding, one queue over. 1538 """ 1539 1540 question_id: str = "" 1541 prompt: str = "" 1542 #: ⚠️ **Required, no default.** The three members are three different 1543 #: repairs, so a fourth value meaning *this server did not say* would 1544 #: be a state with no reader and no remedy — ``AdminTimeseries.metric``'s 1545 #: rule. One producer writes this and it always knows. 1546 state: CoverageState 1547 #: What the Reporting grade said about this question — present only 1548 #: where this run captured it and the report has been graded. 1549 #: 1550 #: ⚠️ **A second axis, never a fourth ``CoverageState``.** Coverage 1551 #: says how much of the range was reached and this says whether the 1552 #: document admits it; they move independently, which is the whole 1553 #: premise of ``#1582``'s two numbers. Folding *found and never 1554 #: described* into the coverage enum rebuilds the one blended figure 1555 #: that made the hundred unreadable. 1556 #: 1557 #: ⚠️ **``None`` carries three absences and that is deliberate**: the 1558 #: run did not capture this, the report was never graded, or the 1559 #: stored verdict is a string this server does not recognise. The 1560 #: first is readable from ``state``; the second from ``graded`` on 1561 #: the envelope; the third is a platform fault that must not be 1562 #: invented into a verdict either way. 1563 verdict: Verdict | None = None
Where one declared question ended one run.
⚠️ The prompt travels because the reader is deciding what to do next,
and dcsync_domain_admin is a slug. A table of slugs is a table
nobody triages — AdminHarnessRow's finding, one queue over.
32class QuestionPublicInfo(CtfyModel): 33 """One question, as exposed to agents and the web UI. 34 35 Carries the prompt + mode + ``choices:`` enum (when present) so the 36 UI can render a radio/checkbox/free-text input without an extra 37 round-trip to fetch the spec. ``answer`` is NEVER projected — the 38 groundtruth lives on the platform either as metadata.yaml or as 39 a per-instance mint. 40 """ 41 42 id: str 43 prompt: str 44 # Grading mode: ``dynamic`` | ``static`` | ``single_select`` | 45 # ``multi_select``. ``None`` only on malformed metadata (which the 46 # audit catches before deploy). 47 mode: str | None = None 48 # Closed-list options for ``single_select`` / ``multi_select``; 49 # ``None`` for free-form modes. 50 choices: list[str] | None = None 51 # Question ids that must be answered correctly first. Both public 52 # surfaces gate on this list: the *instance* projection 53 # (``instances._to_info``) redacts a locked prompt, and the *catalog* 54 # projection (``projections.question_infos``) omits the entry 55 # entirely until it unlocks. 56 # ⚠️ This comment used to claim that gating while the catalog 57 # published every gated prompt, id, ``requires`` edge and the total 58 # count — a false statement about disclosure, written on the model 59 # that was doing the disclosing. Recorded rather than quietly edited 60 # because it read as a guarantee and was believed. 61 requires: list[str] = Field(default_factory=list)
One question, as exposed to agents and the web UI.
Carries the prompt + mode + choices: enum (when present) so the
UI can render a radio/checkbox/free-text input without an extra
round-trip to fetch the spec. answer is NEVER projected — the
groundtruth lives on the platform either as metadata.yaml or as
a per-instance mint.
212class RangeProgress(CtfyModel): 213 """Every team's position on one range of one competition.""" 214 215 competition_id: str = "" 216 challenge_id: str = "" 217 rows: list[RangeProgressRow] = Field(default_factory=list) 218 #: How many milestones the range declares — the denominator for 219 #: ``reached``. ⚠️ ``0`` means the catalog could not resolve the 220 #: challenge, never "no milestones": a reader must render no fraction 221 #: at all rather than dividing by it, the rule 222 #: ``AdminSolveMatrixChallenge.milestones`` already records. 223 milestone_count: int = 0 224 #: ⚠️ ``False`` when the observation read hit its cap. Every tier is 225 #: *positive* evidence, so a truncated read can only make hosts look 226 #: less reached than they were — the direction nobody reports, hence 227 #: a published flag rather than a silent bound. 228 evidence_complete: bool = True
Every team's position on one range of one competition.
145class RangeProgressRow(CtfyModel): 146 """Where one team is on one range, for the people running it. 147 148 The sibling :class:`ChallengeBlueprint` says what the range *is*; 149 this says where a team got to on it, and the two are fetched 150 separately on purpose — the blueprint is a property of the challenge 151 and cacheable, this changes on every save. 152 153 ⚠️ **No network depth here.** "Which layer" wants a BFS from the 154 published segment and that walk has one home already 155 (``frontend/src/lib/segmentDepth.ts``); the client derives the layer 156 from ``compromised`` against the blueprint it is already holding. 157 ``per_segment`` is the raw material for that and needs no ordering 158 of the segments to be true. 159 160 ⚠️ **No groundtruth**, exactly as on the blueprint: this names which 161 milestone *ids* a team holds, never what any of them is worth or 162 what the minted value was. 163 """ 164 165 team_id: str = "" 166 team_name: str = "" 167 #: See :attr:`ScoreboardEntry.anonymous`. An organiser of this 168 #: competition sees through it; anybody else reads the alias. 169 anonymous: bool = False 170 #: ⚠️ Cleared for a withheld row by the same projection that clears 171 #: the name — ``anonymity._board_fields`` argues it: a surface that 172 #: withheld the name and kept the face would be decorative. 173 avatar_url: str = "" 174 reached: list[str] = Field(default_factory=list) 175 #: Reached milestones with at least one unreached successor — where 176 #: this team is stopped. ⚠️ Empty is *both* "nothing reached" and 177 #: "every reached branch is finished"; ``reached`` is what separates 178 #: them, which is why a reader must not key on this alone. 179 frontier: list[str] = Field(default_factory=list) 180 #: Unreached milestones whose prerequisites are all satisfied — what 181 #: this team could take next, including the origins of paths they 182 #: have never touched. 183 available: list[str] = Field(default_factory=list) 184 #: Estate hosts carrying a reached milestone. **Observed.** 185 compromised: list[str] = Field(default_factory=list) 186 #: Estate hosts sharing a segment with a compromised one. 187 #: ⚠️ **Inferred, not observed** — the platform does not know where a 188 #: team's shell is. A renderer that paints this like ``compromised`` 189 #: is asserting a measurement nobody took. 190 reachable: list[str] = Field(default_factory=list) 191 #: ``segment -> [reached, total]`` over milestones carried there. 192 per_segment: dict[str, list[int]] = Field(default_factory=dict) 193 #: Reached milestones **no estate segment carries** — external recon 194 #: and OSINT. ⚠️ Published because it cannot be derived from the two 195 #: fields beside it: ``reached`` is a set and ``per_segment`` is a 196 #: multiset over ``(milestone, segment)`` pairs, so the subtraction 197 #: that suggests itself type-checks and means nothing — 198 #: ``RangeProgress.off_segment`` carries the measurement. 199 off_segment: list[str] = Field(default_factory=list) 200 #: ``host -> EvidenceTier`` — how strongly this team is known to have 201 #: reached each host of the estate. ⚠️ **One mark per host, never a 202 #: stack**: the tiers are strengths, not stages a team walked 203 #: through, and `ctfy.core.evidence` argues why a cumulative 204 #: rendering asserts a history nobody recorded. 205 #: 206 #: ⚠️ An empty map means the blueprint could not be read, never 207 #: "nothing was observed" — every host of a readable estate has an 208 #: entry, including the ones at ``none``. 209 evidence: dict[str, EvidenceTier] = Field(default_factory=dict)
Where one team is on one range, for the people running it.
The sibling ChallengeBlueprint says what the range is;
this says where a team got to on it, and the two are fetched
separately on purpose — the blueprint is a property of the challenge
and cacheable, this changes on every save.
⚠️ No network depth here. "Which layer" wants a BFS from the
published segment and that walk has one home already
(frontend/src/lib/segmentDepth.ts); the client derives the layer
from compromised against the blueprint it is already holding.
per_segment is the raw material for that and needs no ordering
of the segments to be true.
⚠️ No groundtruth, exactly as on the blueprint: this names which milestone ids a team holds, never what any of them is worth or what the minted value was.
276class RangeReplay(CtfyModel): 277 """One range wound back, as a fixed number of frames. 278 279 ⚠️ **Computed server-side and shipped once**, which is a decision 280 with a named alternative: a client that tallied ``per_segment`` 281 itself at time T would be a second implementation of the 282 milestone→segment map, and one round trip per slider step would put 283 a fetch on every drag. So the count of frames is the knob, and the 284 payload is bounded by it rather than by how long the event ran. 285 286 ⚠️ **The clock is ``solved_at``**, so the last frame equals the 287 static ``/progress`` picture element for element — the property the 288 feature rests on — and a capture taken before the window opened is 289 held in frame 0 rather than appearing mid-run. 290 ``ctfy.core.range_replay`` carries the argument. 291 """ 292 293 competition_id: str = "" 294 challenge_id: str = "" 295 #: Ascending in time, both ends of the span included. 296 #: 297 #: ⚠️ **Empty means the range has no time axis** — nothing was 298 #: captured *and* the competition declares no window — never "the 299 #: replay is empty". A reader draws no scrubber at all rather than an 300 #: inert one; ``0`` frames and one frame of zeroes are different 301 #: sentences. 302 frames: list[RangeReplayFrame] = Field(default_factory=list) 303 #: See :attr:`RangeProgress.milestone_count` — ``0`` means the 304 #: catalog could not resolve the challenge, so a reader renders no 305 #: fraction rather than dividing by it. 306 milestone_count: int = 0
One range wound back, as a fixed number of frames.
⚠️ Computed server-side and shipped once, which is a decision
with a named alternative: a client that tallied per_segment
itself at time T would be a second implementation of the
milestone→segment map, and one round trip per slider step would put
a fetch on every drag. So the count of frames is the knob, and the
payload is bounded by it rather than by how long the event ran.
⚠️ The clock is solved_at, so the last frame equals the
static /progress picture element for element — the property the
feature rests on — and a capture taken before the window opened is
held in frame 0 rather than appearing mid-run.
ctfy.core.range_replay carries the argument.
265class RangeReplayFrame(CtfyModel): 266 """Every team's position at one instant.""" 267 268 #: ⚠️ Required and never ``None``: a frame is *the* answer at an 269 #: instant, so a frame that cannot say when it is is not a frame. 270 #: The nullable case is the whole replay being absent, which 271 #: :attr:`RangeReplay.frames` says with an empty list. 272 at: AwareDatetime 273 positions: list[RangeReplayPosition] = Field(default_factory=list)
Every team's position at one instant.
231class RangeReplayPosition(CtfyModel): 232 """One team's coordinate material at one instant of the replay. 233 234 ⚠️ **Narrower than :class:`RangeProgressRow`, and the two fields are 235 the two a *track* reads.** ``frontier`` / ``available`` / 236 ``compromised`` / ``reachable`` / ``evidence`` are what the estate 237 map draws for a single picked team; a replay carries every team at 238 every instant, so publishing all of it would multiply the payload by 239 the milestone count for readers that would drop it. 240 241 ⚠️ **No identity here at all — not even a name.** The page holding 242 this already fetched ``/progress``, whose rows carry the name, the 243 avatar and the anonymity verdict; a second copy travelling per frame 244 would be one more surface to remember when the projection changes, 245 which is exactly how ``anonymise_range_progress`` warns a new field 246 goes out unprojected. The join key is ``team_id``, and the replay 247 publishes a position only for a team ``/progress`` published a row 248 for — see ``admin_range_progress``. 249 """ 250 251 team_id: str = "" 252 #: How many declared milestones this team held at this instant. 253 #: ⚠️ A count, never the id list: the ids are on the progress row 254 #: and do not change per frame in any way a track renders. 255 reached: int = 0 256 #: ``segment -> [reached, total]``, the same shape and the same 257 #: producer as :attr:`RangeProgressRow.per_segment`. 258 per_segment: dict[str, list[int]] = Field(default_factory=dict) 259 #: How many of those reached milestones no estate segment carries. 260 #: ⚠️ A count here and ids on the progress row, for `reached`'s own 261 #: reason: identity lives there, a frame publishes a position. 262 off_segment: int = 0
One team's coordinate material at one instant of the replay.
⚠️ Narrower than RangeProgressRow, and the two fields are
the two a track reads. frontier / available /
compromised / reachable / evidence are what the estate
map draws for a single picked team; a replay carries every team at
every instant, so publishing all of it would multiply the payload by
the milestone count for readers that would drop it.
⚠️ No identity here at all — not even a name. The page holding
this already fetched /progress, whose rows carry the name, the
avatar and the anonymity verdict; a second copy travelling per frame
would be one more surface to remember when the projection changes,
which is exactly how anonymise_range_progress warns a new field
goes out unprojected. The join key is team_id, and the replay
publishes a position only for a team /progress published a row
for — see admin_range_progress.
957class ReactionCount(CtfyModel): 958 """How many people gave one reaction chip.""" 959 960 reaction: Reaction 961 count: int
How many people gave one reaction chip.
368class ReauthenticateRequest(CtfyModel): 369 """Body for ``POST /me/reauthenticate`` — prove a factor again, now. 370 371 Both fields default blank and the server decides which one it wants 372 (``account_deletion.required_proof``), so a client does not have to 373 guess and an account whose answer is ``none`` sends ``{}``. That is 374 the same shape :class:`DeleteMeRequest` takes, on purpose: two 375 request models disagreeing about what counts as proof is the 376 four-routes-four-answers problem this replaces, one layer down. 377 """ 378 379 current_password: str = "" 380 code: str = ""
Body for POST /me/reauthenticate — prove a factor again, now.
Both fields default blank and the server decides which one it wants
(account_deletion.required_proof), so a client does not have to
guess and an account whose answer is none sends {}. That is
the same shape DeleteMeRequest takes, on purpose: two
request models disagreeing about what counts as proof is the
four-routes-four-answers problem this replaces, one layer down.
354class ReauthenticationOpened(CtfyModel): 355 """What ``POST /me/reauthenticate`` answers with. 356 357 ⚠️ **The window travels rather than being hardcoded**, for 358 ``MeResponse.deletion_proof``'s reason: the length is an operator 359 setting, so a client that assumed one would prompt for a proof the 360 server still considers fresh — or, worse, stop prompting while the 361 server has already closed the window, which surfaces as a refusal 362 the user cannot connect to anything they did. 363 """ 364 365 window_seconds: int = 0
What POST /me/reauthenticate answers with.
⚠️ The window travels rather than being hardcoded, for
MeResponse.deletion_proof's reason: the length is an operator
setting, so a client that assumed one would prompt for a proof the
server still considers fresh — or, worse, stop prompting while the
server has already closed the window, which surfaces as a refusal
the user cannot connect to anything they did.
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.
84class RecordAuthoredChallenge(CtfyModel): 85 """One upload into this competition, and who wrote it.""" 86 87 id: str 88 title: str = "" 89 team_id: str = "" 90 uploader_user_id: str = "" 91 author_user_ids: list[str] = Field(default_factory=list) 92 review_status: ReviewStatus = "pending" 93 smoke_verdict: AuthoredSmokeState = "" 94 destination: AuthoredDestination = "public" 95 submitted_at: datetime | None = None 96 #: Whether the challenge is in this competition's set right now. 97 in_challenge_set: bool = False
One upload into this competition, and who wrote it.
61class RecordEvalRun(CtfyModel): 62 """One dispatched agent run.""" 63 64 id: str 65 team_id: str = "" 66 harness_id: str = "" 67 model_id: str = "" 68 challenge_id: str = "" 69 status: EvalRunStatus = "pending" 70 solved: list[str] = Field(default_factory=list) 71 fully_solved: bool = False 72 input_tokens: int = 0 73 output_tokens: int = 0 74 #: Calls the model gateway relayed, as opposed to what the agent 75 #: reported about itself (``usage_source``). 76 relayed_calls: int = 0 77 usage_source: str = "agent" 78 stop_reason: str = "" 79 error: str = "" 80 started_at_ts: float = 0.0 81 finished_at_ts: float = 0.0
One dispatched agent run.
35class RecordSolve(CtfyModel): 36 """One question answered correctly, as the board ranks it.""" 37 38 team_id: str 39 user_id: str = "" 40 challenge_id: str 41 question_id: str = "" 42 solved_at: datetime | None = None 43 #: ``None`` when the solve does not count in this competition — an 44 #: author on their own upload, or a capture outside the window. 45 ranked_at: datetime | None = None
One question answered correctly, as the board ranks it.
48class RecordSubmission(CtfyModel): 49 """One submission. ⚠️ Never the submitted text: a correct one *is* a 50 minted answer, and this file leaves the platform.""" 51 52 id: str 53 team_id: str 54 user_id: str = "" 55 challenge_id: str 56 question_id: str = "" 57 correct: bool = False 58 submitted_at: datetime | None = None
One submission. ⚠️ Never the submitted text: a correct one is a minted answer, and this file leaves the platform.
15class RecordTeam(CtfyModel): 16 """One entrant: a human team, or an agent's synthetic team.""" 17 18 id: str 19 name: str = "" 20 entrant_kind: EntrantKind = "human" 21 #: The members' user ids. A synthetic agent team holds only its 22 #: synthetic user. 23 member_user_ids: list[str] = Field(default_factory=list) 24 #: For an agent team: the harness, the person who registered it, and 25 #: that person's own team in this competition (``""`` when they hold 26 #: none). This is the join an event that credits an agent's score to 27 #: its owner's team needs, and the synthetic id alone cannot answer it. 28 agent_harness_id: str = "" 29 agent_owner_user_id: str = "" 30 agent_owner_team_id: str = "" 31 disqualified: bool = False 32 review_status: str = ""
One entrant: a human team, or an agent's synthetic team.
98class RegisterRequest(CtfyModel): 99 email: EmailStr 100 # Length minimum mirrors ``MIN_PASSWORD_LENGTH`` in 101 # ``ctfy.server.password``. ``validate_password_strength`` runs the 102 # full check (length + common-password reject) inside the route 103 # handler so the rule lives in one place; this floor is a cheap 104 # client-side hint that catches obviously short input before it 105 # reaches argon2. 106 password: str = Field(min_length=12, max_length=256) 107 display_name: str = Field(default="", max_length=120)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
37class RegistrationFieldSpec(CtfyModel): 38 """One field the competition's form should render. 39 40 The server drives the form rather than the frontend hard-coding it, 41 so an organiser flipping a field from optional to required takes 42 effect without a redeploy — and so the client cannot disagree with 43 the server about what is mandatory. 44 """ 45 46 name: str 47 policy: RegistrationFieldPolicy 48 # True for the fields the captain answers once for the whole squad 49 # (team logo, team honours) rather than each member answering. 50 team_scoped: bool = False 51 # True for 个人敏感信息 — the frontend renders the consent notice and 52 # the "organisers only" hint against exactly these. 53 sensitive: bool = False
One field the competition's form should render.
The server drives the form rather than the frontend hard-coding it, so an organiser flipping a field from optional to required takes effect without a redeploy — and so the client cannot disagree with the server about what is mandatory.
56class RegistrationFormSpec(CtfyModel): 57 """Everything the client needs to render + gate the wizard.""" 58 59 competition_id: str 60 fields: list[RegistrationFieldSpec] = Field(default_factory=list) 61 registration_starts_at: datetime | None = None 62 registration_ends_at: datetime | None = None 63 # "not_open" | "open" | "closed" — the client shows a countdown, the 64 # form, or a dead end respectively. 65 phase: Literal["not_open", "open", "closed"] = "open" 66 max_team_size: int = 0 67 review_required: bool = False 68 collects_sensitive_pii: bool = False 69 #: Whether this competition offers to withhold an entrant's identity, 70 #: and what withholding looks like. The wizard renders the control 71 #: only when the policy is not ``forbidden``, and renders it as a 72 #: statement rather than a choice when it is ``required``. 73 #: 74 #: ⚠️ Server-driven like every other field on this spec, and for the 75 #: same reason: a client that decided for itself whether to offer the 76 #: control would be a second copy of the organiser's policy, and the 77 #: copy that is wrong is the one that offers a promise the server 78 #: will not keep. 79 anonymous_entry: AnonymityPolicy = "forbidden" 80 anonymous_entry_kind: AnonymityKind = "pseudonymous"
Everything the client needs to render + gate the wizard.
187class RegistrationRosterRow(CtfyModel): 188 """One entrant on the organiser's roster. 189 190 This is the **only** wire model that carries the sensitive fields, and 191 it is only ever returned from a route gated on 192 ``require_registration_reviewer``. Anything added here is, by 193 construction, organiser-visible personal data — think before widening 194 it. 195 """ 196 197 user_id: str 198 display_name: str = "" 199 email: str = "" 200 team_id: str = "" 201 team_name: str = "" 202 is_captain: bool = False 203 204 real_name: str = "" 205 phone: str = "" 206 mailing_address: str = "" 207 # ⚠️ The same closed set the request carries, not a bare ``str`` — 208 # the `max_level` shape, where the middle layer drops the type and 209 # the client then asserts more than the server promised. 210 gender: Literal["", "male", "female", "other", "undisclosed"] = "" 211 organization: str = "" 212 academic_stage: str = "" 213 grade: str = "" 214 undergrad_school: str = "" 215 specialties: list[str] = Field(default_factory=list) 216 217 submitted_at: datetime | None = None 218 review_status: RegistrationReviewStatus = "approved"
One entrant on the organiser's roster.
This is the only wire model that carries the sensitive fields, and
it is only ever returned from a route gated on
require_registration_reviewer. Anything added here is, by
construction, organiser-visible personal data — think before widening
it.
83class RegistrationSubmitRequest(CtfyModel): 84 """Body for ``PUT /competitions/{id}/registration``. 85 86 Every field is optional at the type level; which ones are actually 87 mandatory is decided per competition at request time against 88 ``registration_fields``. Encoding "required" in the type would hard- 89 code one event's rules into the wire contract. 90 """ 91 92 real_name: str = Field(default="", max_length=120) 93 phone: str = Field(default="", max_length=40) 94 mailing_address: str = Field(default="", max_length=500) 95 gender: Literal["", "male", "female", "other", "undisclosed"] = "" 96 organization: str = Field(default="", max_length=200) 97 academic_stage: str = Field(default="", max_length=80) 98 grade: str = Field(default="", max_length=80) 99 undergrad_school: str = Field(default="", max_length=200) 100 specialties: list[str] = Field(default_factory=list) 101 # Captain-only; ignored (not rejected) when a non-captain sends them, 102 # so a member re-submitting a form the wizard prefilled does not 422. 103 team_honors: str = Field(default="", max_length=2000) 104 105 # ⚠️ **``None`` means "the form did not ask", and these two are the 106 # deliberate exception to this model's full-replace semantics.** 107 # Every other field here defaults to ``""`` because the wizard 108 # renders all of them and submits all of them, so an omission really 109 # is a clear. That reading is catastrophic for a privacy flag: a 110 # client that predates the field cannot render it, so a ``False`` 111 # default would make every save from an older tab publish an entrant 112 # who asked to be withheld — silently, and with no way to know it 113 # happened. Absent is untouched. 114 #: 115 #: ``anonymous`` is the entrant's own name; ``team_anonymous`` is the 116 #: squad's row and is captain-only, ignored (not rejected) from a 117 #: member for the same reason ``team_honors`` is. 118 anonymous: bool | None = None 119 team_anonymous: bool | None = None 120 121 @field_validator("phone") 122 @classmethod 123 def _check_phone(cls, v: str) -> str: 124 return validate_phone_shape(v) 125 126 @field_validator("specialties") 127 @classmethod 128 def _check_specialties(cls, v: list[str]) -> list[str]: 129 if len(v) > 12: 130 raise ValueError("specialties may contain at most 12 entries") 131 if any(len(s) > 40 for s in v): 132 raise ValueError("each specialty must be at most 40 characters") 133 return v
Body for PUT /competitions/{id}/registration.
Every field is optional at the type level; which ones are actually
mandatory is decided per competition at request time against
registration_fields. Encoding "required" in the type would hard-
code one event's rules into the wire contract.
255class RegistrationSummary(CtfyModel): 256 """Per-verdict totals for one competition's roster. 257 258 Split from the roster itself because the filter tabs need counts for 259 the *whole* competition while the list is paged — counting the rows 260 in hand would make every tab report the page size. It is also far 261 cheaper: a grouped ``COUNT`` rather than a page of decrypted 262 dossiers, which is why it can be fetched on every tab render. 263 264 Carries no personal data by construction, so it is not audited as a 265 PII read. 266 """ 267 268 competition_id: str 269 total: int = 0 270 pending: int = 0 271 approved: int = 0 272 rejected: int = 0 273 # Teams with at least one member missing a required answer. Counted 274 # in SQL against blank columns, never by reading the dossiers — an 275 # unanswered sealed field is stored empty, so this stays a count and 276 # not a bulk decrypt. Teams rather than people because that is the 277 # unit a reviewer works through. 278 incomplete_teams: int = 0
Per-verdict totals for one competition's roster.
Split from the roster itself because the filter tabs need counts for
the whole competition while the list is paged — counting the rows
in hand would make every tab report the page size. It is also far
cheaper: a grouped COUNT rather than a page of decrypted
dossiers, which is why it can be fetched on every tab render.
Carries no personal data by construction, so it is not audited as a PII read.
221class RegistrationTeamRow(CtfyModel): 222 """One squad on the organiser's review queue. 223 224 Review is a team-level verdict, so the queue is grouped by team with 225 the members nested — an organiser approves a squad after reading all 226 of its members' details, never one person at a time. 227 """ 228 229 team_id: str 230 team_name: str = "" 231 description: str = "" 232 honors: str = "" 233 has_logo: bool = False 234 captain_user_id: str = "" 235 created_at: datetime | None = None 236 review_status: RegistrationReviewStatus = "approved" 237 reviewed_by: str = "" 238 reviewed_by_name: str = "" 239 reviewed_at: datetime | None = None 240 review_note: str = "" 241 members: list[RegistrationRosterRow] = Field(default_factory=list) 242 # Members who have not completed the form yet. Surfaced as a count so 243 # the reviewer can spot an incomplete squad without opening it. 244 incomplete_members: int = 0 245 # True when a member's dossier changed after the verdict was recorded, 246 # which makes that verdict stale. Derived from ``reviewed_at`` against 247 # the members' ``updated_at`` rather than stored: a column would have 248 # to be reset on every review and cleared on every edit, and a missed 249 # write there reads exactly like "nothing changed". The alternative — 250 # reverting the team to ``pending`` on any edit — would force a 251 # re-review over a corrected phone digit. 252 edited_after_review: bool = False
One squad on the organiser's review queue.
Review is a team-level verdict, so the queue is grouped by team with the members nested — an organiser approves a squad after reading all of its members' details, never one person at a time.
126class RegistryGcStatus(CtfyModel): 127 """Whether and how the registry can reclaim the blobs behind deletes. 128 129 Deleting a manifest frees only its reference; the bytes stay until the 130 registry garbage-collects. This is the probe + preview the operator 131 reads before running that — it never runs GC itself, because a stock 132 ``registry:2`` exposes no remote GC and the platform holds no shell on 133 the registry container. 134 """ 135 136 reachable: bool = False 137 #: The ``Docker-Distribution-Api-Version`` the registry advertised. 138 api_version: str = "" 139 mode: RegistryGcMode = RegistryGcMode.UNAVAILABLE 140 #: Bytes the current reclaim policies would free once their manifests 141 #: are deleted and GC has run — a preview, summed over both policies. 142 reclaimable_bytes: int = 0 143 reclaimable_manifests: int = 0 144 #: The exact host command an operator runs to garbage-collect, or 145 #: ``""`` when the registry is unreachable. 146 command: str = "" 147 #: A human-readable summary of the mechanism and any caveat. 148 message: str = ""
Whether and how the registry can reclaim the blobs behind deletes.
Deleting a manifest frees only its reference; the bytes stay until the
registry garbage-collects. This is the probe + preview the operator
reads before running that — it never runs GC itself, because a stock
registry:2 exposes no remote GC and the platform holds no shell on
the registry container.
52class RegistryManifestRow(CtfyModel): 53 """One tag, with why it is protected and whether a reclaim would take it.""" 54 55 tag: str 56 #: The manifest digest a delete addresses; ``""`` if it could not be 57 #: read (which leaves it un-deletable, the safe direction). 58 digest: str = "" 59 size_bytes: int = 0 60 #: The image config's ``created`` time — the closest the registry API 61 #: offers to a push time; ``None`` when it could not be read. 62 created_at: AwareDatetime | None = None 63 kind: RegistryRepoKind 64 #: Why this manifest may not be reclaimed. Empty means nothing 65 #: references it. 66 references: list[RegistryReferenceReason] = Field(default_factory=list) 67 #: ⚠️ Composed server-side from the reference set and the reclaim 68 #: policies, never re-derived in the browser — the same rule 69 #: ``RegistryUsageRow.over_quota`` follows. A ``true`` here means some 70 #: policy would remove it; the manual delete still refuses a pinned 71 #: digest regardless. 72 reclaimable: bool = False
One tag, with why it is protected and whether a reclaim would take it.
648class RegistryPushTarget(CtfyModel): 649 """Where this account pushes an agent image, if anywhere. 650 651 ⚠️ ``enabled`` is composed **server-side** even though a non-empty 652 ``repository_prefix`` implies it. A component could derive it — and 653 then two clients disagree about whether the deployment has a 654 registry. Same rule as ``expects_report``: the browser is handed the 655 verdict, not the ingredients. 656 657 ⚠️ ``enabled`` means *a push would work*, not "an operator set a 658 hostname". A domain with no signing pair behind it is a registry that 659 refuses every login, and a page that printed a push command for one 660 would be sending users at a wall. 661 """ 662 663 enabled: bool = False 664 #: Blank whenever ``enabled`` is false — a half-filled answer becomes 665 #: ``docker push /uid/agent``, which fails on a malformed reference 666 #: rather than on anything a reader could act on. 667 host: str = "" 668 #: ``<host>/<namespace>``. Built here rather than in the client 669 #: because the namespace scheme is what ``registry_scopes`` decides 670 #: and what the token route authorises against; a client 671 #: concatenating its own keeps working until the two disagree. 672 repository_prefix: str = ""
Where this account pushes an agent image, if anywhere.
⚠️ enabled is composed server-side even though a non-empty
repository_prefix implies it. A component could derive it — and
then two clients disagree about whether the deployment has a
registry. Same rule as expects_report: the browser is handed the
verdict, not the ingredients.
⚠️ enabled means a push would work, not "an operator set a
hostname". A domain with no signing pair behind it is a registry that
refuses every login, and a page that printed a push command for one
would be sending users at a wall.
609class RegistryQuotaInfo(CtfyModel): 610 """The ceiling the rows are judged against. 611 612 ⚠️ Sent with the report because a number with no ceiling beside it is 613 not a report: "40 GiB" answers nothing, "40 of 50" is the question 614 the operator opened the page to ask. 615 """ 616 617 #: ``0`` means the dimension is off — the same reading everywhere 618 #: else, never "nothing allowed". 619 max_bytes: int = 0 620 max_tags: int = 0
The ceiling the rows are judged against.
⚠️ Sent with the report because a number with no ceiling beside it is not a report: "40 GiB" answers nothing, "40 of 50" is the question the operator opened the page to ask.
84class RegistryReclaimItem(CtfyModel): 85 """One manifest a reclaim run would remove, or refused to.""" 86 87 repository: str 88 tag: str 89 digest: str = "" 90 size_bytes: int = 0 91 #: Populated on the refused list — why the policy kept it. 92 references: list[RegistryReferenceReason] = Field(default_factory=list)
One manifest a reclaim run would remove, or refused to.
106class RegistryReclaimPlan(CtfyModel): 107 """What one policy would remove (dry run) or removed (executed).""" 108 109 policy: RegistryReclaimPolicy 110 dry_run: bool = True 111 #: What would be / was removed, and what the policy protected. 112 reclaim: list[RegistryReclaimItem] = Field(default_factory=list) 113 refused: list[RegistryReclaimItem] = Field(default_factory=list) 114 reclaim_count: int = 0 115 reclaim_bytes: int = 0 116 refused_count: int = 0 117 #: How many were actually deleted (``0`` on a dry run). ⚠️ A separate 118 #: number from ``reclaim_count`` so a partial execution — some deletes 119 #: the registry refused — is visible rather than assumed complete. 120 deleted: int = 0 121 #: Whether the catalog walk behind the plan was capped or hit an 122 #: unreadable repository; a plan over an incomplete view under-removes. 123 incomplete: bool = False
What one policy would remove (dry run) or removed (executed).
95class RegistryReclaimRequest(CtfyModel): 96 """Ask a reclaim policy what it would remove, or to remove it. 97 98 ⚠️ ``dry_run`` defaults **true**: the preview is the default and the 99 destructive act is the explicit one, per the issue's "dry-run first". 100 """ 101 102 policy: RegistryReclaimPolicy 103 dry_run: bool = True
Ask a reclaim policy what it would remove, or to remove it.
⚠️ dry_run defaults true: the preview is the default and the
destructive act is the explicit one, per the issue's "dry-run first".
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
Every repository, for the browse page's landing list.
75class RegistryTagList(CtfyModel): 76 """The tags of one repository, for the browse drill-down.""" 77 78 repository: str 79 items: list[RegistryManifestRow] = Field(default_factory=list) 80 total: int = 0 81 incomplete: bool = False
The tags of one repository, for the browse drill-down.
623class RegistryUsageReport(CtfyModel): 624 """Who holds what in the platform's own registry.""" 625 626 items: list[RegistryUsageRow] = Field(default_factory=list) 627 quota: RegistryQuotaInfo = Field(default_factory=RegistryQuotaInfo) 628 #: When the scan last *completed*, read off the receipt it writes on 629 #: the far side of a finished walk. ⚠️ It used to be 630 #: ``max(measured_at)`` over the rows, which cannot answer the 631 #: question this field is for: with no rows that is `None` both for a 632 #: registry nobody has pushed to and for a scan that has never once 633 #: worked, and the page then says "not measured **yet**" about a loop 634 #: that will never measure anything. 635 scanned_at: AwareDatetime | None = None 636 #: Whether the quota is deciding anything right now, composed 637 #: **server-side** from that receipt. ⚠️ The verdict rather than the 638 #: timestamp, for the reason ``RegistryPushTarget.enabled`` gives 639 #: below: a page dividing a date by an interval is a second 640 #: implementation of the rule, and the copy an operator reads would 641 #: eventually disagree with the thing actually admitting pushes. 642 #: ⚠️ Defaults to ``in_force`` so an older server does not paint an 643 #: alarm across a page that is fine — the same direction 644 #: ``MySolveSummary.ranked`` defaults in, and for the same reason. 645 enforcement: Enforcement = "in_force"
Who holds what in the platform's own registry.
573class RegistryUsageRow(CtfyModel): 574 """One account's registry footprint, as the operator's report shows it.""" 575 576 user_id: str 577 display_name: str = "" 578 bytes: int = 0 579 tags: int = 0 580 repos: int = 0 581 #: The repository names, so the reclaim entry point beside this table 582 #: has something to point at. ⚠️ **Not read off the harness table.** 583 #: The push grant covers ``<user>/*``, so an account holds 584 #: repositories ctfy was never told about — which is precisely the 585 #: storage nobody is watching, and precisely what a report built 586 #: from registered images would omit. 587 repositories: list[str] = Field(default_factory=list) 588 measured_at: AwareDatetime | None = None 589 #: ⚠️ Carried, never folded into the numbers. A namespace whose 590 #: manifests half-failed *undercounts*, and an undercount presented 591 #: as fact is how an operator concludes an account is fine when 592 #: nobody could read half of it. 593 incomplete: bool = False 594 #: ⚠️ Composed **server-side** from the same ``check_quota`` the push 595 #: path uses, never re-derived in the browser from the two numbers 596 #: below. A page that did its own comparison would eventually 597 #: disagree with the thing that actually refuses pushes — and the 598 #: one nobody reads is the one that decides. 599 over_quota: bool = False 600 #: Whether this namespace is the deployment's platform account (the 601 #: one ``CTFY_IMAGE`` names), whose images — the frozen ``chal-…`` 602 #: repositories, the deploy's own — are the platform's and are 603 #: charged to nobody (#1863). ⚠️ Composed server-side from the same 604 #: ``is_charged`` the push path asks, so the page cannot call a row 605 #: exempt that the token route still narrows. 606 platform_account: bool = False
One account's registry footprint, as the operator's report shows it.
563class RenewResponse(CtfyModel): 564 #: ⚠️ **An acknowledgement, not a lifecycle status.** Its two 565 #: siblings above report an :class:`InstanceStatus`; ``"renewed"`` is 566 #: not one — a renewed instance is still ``ready``. The field name 567 #: was the only thing suggesting otherwise, and a shared name across 568 #: three responses is what made the difference invisible. Typed as 569 #: the constant it is, so the next reader does not have to work out 570 #: which vocabulary this one speaks. 571 status: Literal["renewed"] = "renewed" 572 id: str 573 expires_at: float
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
229class ReportAttachmentInfo(CtfyModel): 230 """One evidence file in a team's report appendix. 231 232 ⚠️ **No bytes and no URL.** The bytes are fetched from 233 ``GET .../report/attachments/{id}`` — proxied through the platform 234 like a team logo, so the bucket needs no public-read policy and an 235 unpublished competition's evidence is not enumerable by anyone who 236 guesses a key. Embedding the object key here would publish exactly 237 that guess. 238 """ 239 240 id: str = "" 241 #: The name the uploader gave, sanitised for display. ⚠️ Display 242 #: only — the object key is derived from ``id``, so this string 243 #: addresses nothing. 244 filename: str = "" 245 #: **Sniffed from the bytes.** A real image type means a client may 246 #: render a thumbnail; ``application/octet-stream`` means the file is 247 #: a download and nothing more. 248 content_type: str = "" 249 size_bytes: int = 0 250 #: Who uploaded it — the team owns the evidence, the user is the 251 #: audit, the same split ``ReportInfo.user_id`` makes. 252 user_id: str = "" 253 uploaded_at: datetime | None = None
One evidence file in a team's report appendix.
⚠️ No bytes and no URL. The bytes are fetched from
GET .../report/attachments/{id} — proxied through the platform
like a team logo, so the bucket needs no public-read policy and an
unpublished competition's evidence is not enumerable by anyone who
guesses a key. Embedding the object key here would publish exactly
that guess.
256class ReportAttachmentsResponse(CtfyModel): 257 """One engagement's appendix, oldest first. 258 259 Oldest first because a reference in the prose counts from the order 260 the team built the appendix in, and a newest-first list silently 261 renumbers it on every upload. 262 """ 263 264 competition_id: str = "" 265 team_id: str = "" 266 challenge_id: str = "" 267 attachments: list[ReportAttachmentInfo] = Field(default_factory=list) 268 #: Whether this deployment can accept an upload at all. 269 #: 270 #: ⚠️ **An empty appendix has to say which emptiness it is.** With no 271 #: object storage configured the list route still answers (reading 272 #: back what you submitted is not a submission), so an empty array 273 #: reads as "this team uploaded nothing" when it means "nobody can" — 274 #: and the player only discovers the difference by trying. Same 275 #: argument as ``webhook_configured`` on the email-suppression list 276 #: and ``gateway_configured`` on the LLM budget roster. 277 #: 278 #: Defaults ``False`` because that is the answer that is *not* a new 279 #: claim: an older server sending nothing must not make a client 280 #: promise an upload the deployment cannot perform. 281 uploads_enabled: bool = False 282 #: How many more this engagement may take. 283 #: 284 #: Served rather than derived from ``len(attachments)`` against a 285 #: constant the client restates: a ceiling spelled in two places is 286 #: one a client eventually disagrees with, and the direction it 287 #: disagrees in is a disabled upload button on an engagement that had 288 #: room. Zero means the appendix is full — a real state, distinct 289 #: from an appendix that is merely empty. 290 remaining: int = 0
One engagement's appendix, oldest first.
Oldest first because a reference in the prose counts from the order the team built the appendix in, and a newest-first list silently renumbers it on every upload.
546class ReportClaimRow(CtfyModel): 547 """One finding a report claims that the platform could not place. 548 549 ``#1582``'s second operator question. ⚠️ **Never scored**: whether an 550 unintended path is a finding or a defect in the range is a judgement, 551 and a grader that scored it would be paying the model for the 552 scaffolding's mistakes. 553 554 ⚠️ **``quote`` is the team's prose and ``title`` is the model's 555 paraphrase.** A row only exists once the quote was located verbatim 556 in the report, so what an operator reads is always something the 557 team wrote — with a label attached for scanning. 558 """ 559 560 #: Position in the report's own order, which is the model's 561 #: enumeration order. Published so a reader can cite a row. 562 ordinal: int = 0 563 title: str = "" 564 #: Empty is an ordinary answer: a finding that is a condition rather 565 #: than a value turns on no single string. 566 value: str = "" 567 quote: str = ""
One finding a report claims that the platform could not place.
#1582's second operator question. ⚠️ Never scored: whether an
unintended path is a finding or a defect in the range is a judgement,
and a grader that scored it would be paying the model for the
scaffolding's mistakes.
⚠️ quote is the team's prose and title is the model's
paraphrase. A row only exists once the quote was located verbatim
in the report, so what an operator reads is always something the
team wrote — with a label attached for scanning.
570class ReportClaimsPage(CtfyModel): 571 """What one team's report claims beyond what the range declares. 572 573 ⚠️ **``read`` is the field that makes the rest honest.** Zero rows 574 has three causes: the platform read the report and could place 575 everything, nothing ever looked, or the model call was refused. Only 576 the first is a fact about the team. Without this flag an operator 577 reading an empty table cannot tell "they found nothing we did not 578 plant" from "this deployment has no gateway", and those get opposite 579 responses. 580 581 ⚠️ **Defaults ``False``**, so a server that does not send it reads as 582 *not read* rather than as a clean sheet — the claims-nothing 583 direction, since the alarming reading here is the reassuring one. 584 """ 585 586 #: Which saved version was read. ``0`` when nothing has been. 587 report_version: int = 0 588 #: Has any version of this report been read at all. 589 read: bool = False 590 #: The rows this page carries, already cut to the operator's setting. 591 rows: list[ReportClaimRow] = Field(default_factory=list) 592 #: How many the reading found, before the cut. ⚠️ A route that takes 593 #: a limit owes a total — ``rows`` shorter than this is a list that 594 #: was cut, and without the number a short list reads as a quiet 595 #: report. 596 total: int = 0 597 #: How many claims the platform *could* tie to a declared question. 598 #: Published because it is what says the subtraction ran at all: a 599 #: long list beside ``accounted: 0`` is a reading with nothing to 600 #: subtract against, which is a different problem from a range with 601 #: unintended paths in it. 602 accounted: int = 0 603 #: How many spans the model produced that are not in the report. 604 #: ⚠️ A signal about the *deployment*, never about the team — it 605 #: counts the model inventing, and its sibling on the grading side 606 #: is ``unreadable_answers``. 607 ungrounded: int = 0
What one team's report claims beyond what the range declares.
⚠️ read is the field that makes the rest honest. Zero rows
has three causes: the platform read the report and could place
everything, nothing ever looked, or the model call was refused. Only
the first is a fact about the team. Without this flag an operator
reading an empty table cannot tell "they found nothing we did not
plant" from "this deployment has no gateway", and those get opposite
responses.
⚠️ Defaults False, so a server that does not send it reads as
not read rather than as a clean sheet — the claims-nothing
direction, since the alarming reading here is the reassuring one.
328class ReportGradeInfo(CtfyModel): 329 """One grading run in full, per-fact verdicts included. 330 331 What an appeal argues from, so it carries the half a count cannot: 332 *which* finding was called inaccurate. Organiser-only — 333 ``unsupported`` beside a fact key is an accusation with a name on it. 334 """ 335 336 team_id: str = "" 337 challenge_id: str = "" 338 report_version: int = 0 339 #: Per-fact verdicts keyed ``challenge_id:question_id``. Kept whole 340 #: rather than only the non-``accurate`` ones, so a run states the 341 #: entire fact set it was computed over — otherwise an absent key 342 #: means both "graded accurate" and "was not a fact at the time". 343 verdicts: dict[str, str] = Field(default_factory=dict) 344 accurate: int = 0 345 inaccurate: int = 0 346 not_mentioned: int = 0 347 unsupported: int = 0 348 points: int = 0 349 graded_at: datetime | None = None
One grading run in full, per-fact verdicts included.
What an appeal argues from, so it carries the half a count cannot:
which finding was called inaccurate. Organiser-only —
unsupported beside a fact key is an accusation with a name on it.
293class ReportGradeSummary(CtfyModel): 294 """One team's live Reporting verdict on one range, without the detail. 295 296 The counts and the price, for a roster row and a wall panel. The 297 per-fact verdicts are one route further in 298 (:class:`ReportGradeInfo`), the same split the report body itself 299 makes: a table of "how did everyone do" must not carry thirty-three 300 judgements per row. 301 302 ⚠️ **``None`` on a row, never an all-zero summary.** A report graded 303 against an empty fact set records exactly this object with every 304 count at zero — a real verdict, produced with no model call, about a 305 team that wrote up a range it never got into — so a zeroed default 306 would be indistinguishable from *never graded*. The two are opposite 307 facts about whether the platform owes the team anything. 308 """ 309 310 #: Which saved version this verdict is about. ⚠️ Compare it with the 311 #: row's own ``version``: a team that has saved again since is 312 #: reading a judgement of a draft they have replaced, and that is 313 #: the whole reason the grade row records a version at all. 314 report_version: int = 0 315 accurate: int = 0 316 inaccurate: int = 0 317 not_mentioned: int = 0 318 #: Findings claimed with nothing behind them — §3.4's fabrication 319 #: count, and the reason this whole object is organiser-facing. 320 unsupported: int = 0 321 #: What those counts were worth **under the weights in force when 322 #: the verdict was produced**. Never re-priced downstream: a model's 323 #: verdict is not reproducible, so that row is its only account. 324 points: int = 0 325 graded_at: datetime | None = None
One team's live Reporting verdict on one range, without the detail.
The counts and the price, for a roster row and a wall panel. The
per-fact verdicts are one route further in
(ReportGradeInfo), the same split the report body itself
makes: a table of "how did everyone do" must not carry thirty-three
judgements per row.
⚠️ None on a row, never an all-zero summary. A report graded
against an empty fact set records exactly this object with every
count at zero — a real verdict, produced with no model call, about a
team that wrote up a range it never got into — so a zeroed default
would be indistinguishable from never graded. The two are opposite
facts about whether the platform owes the team anything.
352class ReportGradesResponse(CtfyModel): 353 """Every grading run on one team's report for one range, newest first. 354 355 ⚠️ **Its own organiser route rather than a field on 356 :class:`ReportVersionsResponse`**, which a player reads for their own 357 history. Widening that one would make an empty ``runs`` list mean 358 both "never graded" and "not disclosed to you" — and the route that 359 must never serve these would then be one forgotten branch away from 360 doing it, instead of being structurally unable to. 361 """ 362 363 competition_id: str = "" 364 team_id: str = "" 365 challenge_id: str = "" 366 runs: list[ReportGradeInfo] = Field(default_factory=list)
Every grading run on one team's report for one range, newest first.
⚠️ Its own organiser route rather than a field on
ReportVersionsResponse, which a player reads for their own
history. Widening that one would make an empty runs list mean
both "never graded" and "not disclosed to you" — and the route that
must never serve these would then be one forgotten branch away from
doing it, instead of being structurally unable to.
32class ReportInfo(CtfyModel): 33 """One saved version, as its own team reads it back. 34 35 ⚠️ ``content`` is attacker-authored text — every byte is written by 36 whoever is attacking the range. It is carried and stored as plain 37 markdown, never rendered as HTML by anything that receives it, and 38 when the grading half lands it reaches a model only through the 39 bounded matching prompt described in the plan's §5.4. 40 """ 41 42 competition_id: str = "" 43 team_id: str = "" 44 #: The range this report is about. ⚠️ Empty on a **pre-split** row — 45 #: one written before the report was per challenge, when it covered 46 #: the whole competition. Those are read back unchanged; nothing 47 #: writes an empty one any more. 48 challenge_id: str = "" 49 #: Who pressed save on *this* version. The team is scored; the user 50 #: is the audit. 51 user_id: str = "" 52 #: 1-based, monotonic within (team, competition, challenge). The 53 #: current report is the highest; nothing renumbers. 54 version: int = 0 55 content: str = "" 56 saved_at: datetime | None = None 57 #: When the team asked for **this version** to be judged now rather 58 #: than when the quiet window elapses. ``None`` is the ordinary 59 #: state; a save writes a fresh version without it, because a team 60 #: that carried on writing has withdrawn the claim that the document 61 #: was ready. 62 grading_requested_at: datetime | None = None 63 #: When the team handed this version in — 交卷. While it is 64 #: set, the save route refuses a new version. 65 #: 66 #: ⚠️ **The timestamp, never a bool.** "When did they hand it in" is 67 #: the question a late-submission dispute asks, and it is the one a 68 #: boolean cannot answer at all. 69 finalized_at: datetime | None = None
One saved version, as its own team reads it back.
⚠️ content is attacker-authored text — every byte is written by
whoever is attacking the range. It is carried and stored as plain
markdown, never rendered as HTML by anything that receives it, and
when the grading half lands it reaches a model only through the
bounded matching prompt described in the plan's §5.4.
388class ReportPipeline(CtfyModel): 389 """The whole deployment's report-grading pipeline, in one read. 390 391 The competition-scoped roster answers "is *my* event moving"; this 392 answers "is the grader moving at all", which is a different question 393 with a different failure. A backlog nothing drains is byte-identical 394 to a busy one — the job leaves an ungradeable report due on purpose 395 and records ``succeeded`` while doing it — so the two facts that 396 decide whether a verdict can be produced travel beside the numbers 397 rather than being inferred from them. 398 """ 399 400 #: Due across every exercise competition. 401 backlog: int = 0 402 #: Saved and ungraded but not yet quiet, across every one of them. 403 waiting: int = 0 404 #: In the job's own walk order; see :class:`ReportPipelineEvent`. 405 events: list[ReportPipelineEvent] = Field(default_factory=list) 406 #: How many reports one tick will grade, across the whole platform. 407 #: Served because it is what turns a backlog into a *duration*, and 408 #: it is a constant the client would otherwise have to restate. 409 max_per_tick: int = 0 410 #: How often that tick comes round, in seconds. 411 interval_seconds: int = 0 412 #: How long a report must sit untouched to become due. 413 quiet_seconds: int = 0 414 #: How long the oldest **due** report has been waiting, in seconds. 415 #: 416 #: ⚠️ **The reading this model's own last paragraph asks for.** It 417 #: says what proves the pipeline is working is "the backlog going 418 #: down" — which one snapshot cannot show, because a backlog nothing 419 #: drains and a busy one are the same two counts. This is the fact 420 #: that separates them without a second sample. 421 #: 422 #: ⚠️ **``None`` covers two absences and ``0.0`` covers neither**: 423 #: an empty queue has no oldest row, and a queue whose due rows are 424 #: all unstamped has one with no readable age. A zero would read as 425 #: *a report arrived a moment ago*, the opposite of *nothing is 426 #: due*, and the two get opposite responses. ``backlog`` beside it 427 #: is what tells an absent age apart from a server that did not say. 428 #: 429 #: ⚠️ **Platform-wide, and deliberately not on 430 #: :class:`ReportPipelineEvent`.** A per-event age is a true fact 431 #: that reads as a verdict about that event, while the cause is 432 #: usually the events *ahead* of it in the one shared per-tick 433 #: ceiling — which is the misreading this whole model exists to 434 #: prevent. 435 oldest_due_seconds: float | None = None 436 #: Is the recurring loop switched on for this deployment. ⚠️ Both 437 #: gates default ``False``, the answer that is not a new claim: an 438 #: older server's silence must not render as "grading is running". 439 grading_enabled: bool = False 440 #: Is there an LLM gateway for the grader's own grant to be spent 441 #: through. Without one the grader returns before building a prompt 442 #: and every report simply stays due. 443 grading_configured: bool = False 444 #: When the loop last ran, and what it said. ⚠️ **Its own report, so 445 #: it is evidence rather than health** — the job records ``ok`` for a 446 #: run that caught its own failure and returned the message. It is 447 #: published so an operator can see the pipeline is *ticking*; what 448 #: says it is *working* is the backlog going down. 449 last_run_at: datetime | None = None 450 #: ⚠️ Typed, not ``str``: it is a three-member closed set 451 #: (``ok`` / ``error`` / ``""`` for never run), and ``""`` here is 452 #: *never run on this deployment* rather than a fourth outcome. 453 last_status: JobRunStatus = "" 454 last_summary: str = ""
The whole deployment's report-grading pipeline, in one read.
The competition-scoped roster answers "is my event moving"; this
answers "is the grader moving at all", which is a different question
with a different failure. A backlog nothing drains is byte-identical
to a busy one — the job leaves an ungradeable report due on purpose
and records succeeded while doing it — so the two facts that
decide whether a verdict can be produced travel beside the numbers
rather than being inferred from them.
369class ReportPipelineEvent(CtfyModel): 370 """One exercise competition's share of the platform-wide backlog. 371 372 ⚠️ **In the order the grading job walks it**, never sorted by size. 373 ``MAX_REPORTS_PER_TICK`` is a ceiling *across every competition*, so 374 the job drains them in sequence and a competition late in that 375 sequence is starved by the ones ahead of it. A list ordered by 376 backlog would put the starving event first and read as the opposite 377 of what is happening. 378 """ 379 380 competition_id: str = "" 381 title: str = "" 382 #: Reports due right now — quiet, or pushed past the window by hand. 383 queued: int = 0 384 #: Saved, ungraded, and not yet quiet. 385 waiting: int = 0
One exercise competition's share of the platform-wide backlog.
⚠️ In the order the grading job walks it, never sorted by size.
MAX_REPORTS_PER_TICK is a ceiling across every competition, so
the job drains them in sequence and a competition late in that
sequence is starved by the ones ahead of it. A list ordered by
backlog would put the starving event first and read as the opposite
of what is happening.
503class ReportRosterResponse(CtfyModel): 504 competition_id: str = "" 505 rows: list[ReportRosterRow] = Field(default_factory=list) 506 #: Teams registered for this competition that have saved nothing **at 507 #: all**, in any range. Served rather than derived: a client would 508 #: have to fetch the full team list to subtract, and two clients 509 #: doing that arithmetic differently is two organisers disagreeing 510 #: about who is missing. 511 #: 512 #: ⚠️ Deliberately *not* "unwritten ``(team, range)`` pairs". That 513 #: number is ``teams × ranges`` for an event nobody has started and 514 #: grows with a bucket competition's whole catalog, so it answers a 515 #: question no organiser asked while looking like the one they did. 516 missing_team_count: int = 0 517 #: How many reports are queued for grading right now. 518 #: 519 #: ⚠️ **Not served for the client's arithmetic** — it holds every row 520 #: and can count them — but because it is the *headline*: an 521 #: organiser wants "18 awaiting judgement" with no table underneath 522 #: it, and a tally the page did itself would be a second answer free 523 #: to disagree with the one the wall is showing the room. 524 queue_length: int = 0 525 #: Seconds a report must sit untouched before it becomes due, so the 526 #: page can say how long ``waiting`` lasts rather than only that it 527 #: is not over. 528 quiet_seconds: int = 0 529 #: Is the recurring grading loop switched on for this deployment. 530 #: 531 #: ⚠️ **Both of these travel with the backlog because the backlog 532 #: cannot say it.** A queue nothing is draining looks exactly like a 533 #: busy one — the job leaves an ungradeable report due on purpose, 534 #: and records ``succeeded`` while doing it — so an organiser reading 535 #: only the rows would see a healthy event right up until the 536 #: Reporting column stayed empty at the close. Defaults ``False`` 537 #: because that is the answer that is not a new claim: an older 538 #: server's silence must not render as "grading is running". 539 grading_enabled: bool = False 540 #: Is there an LLM gateway for the platform's own grant to be spent 541 #: through. Without one the grader returns before building a prompt 542 #: and the report simply stays due. 543 grading_configured: bool = False
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
457class ReportRosterRow(CtfyModel): 458 """One team's report *status* on the organiser's roster. 459 460 ⚠️ **No body.** An event accumulates reports all week and a table of 461 "who has written one" must not read every one of them out of SQLite 462 to render a row count — the same argument that made 463 ``list_patch_submissions_admin`` name its columns rather than select 464 the entity. The body is one route further in, one team at a time. 465 """ 466 467 team_id: str = "" 468 team_name: str = "" 469 #: The range. ⚠️ Carried rather than named: a ``challenge_id`` is 470 #: already human-readable (``GAUNTLET-FIN-001``), unlike a team's 471 #: uuid, so a second lookup would buy nothing — the same reading 472 #: ``EntityCell``'s own note records. 473 challenge_id: str = "" 474 version: int = 0 475 #: Length of the current version, in characters. What an organiser 476 #: scans the roster for: who has written nothing, who has written a 477 #: paragraph, who has written a report. 478 length: int = 0 479 saved_at: datetime | None = None 480 #: Where this write-up stands with the report grader. 481 status: ReportGradeStatus = "not_scored" 482 #: 1-based place in the grading queue, ``0`` unless ``status`` is 483 #: ``queued``. The organiser's is never withheld — the queue is the 484 #: thing they are on this page to manage. 485 queue_position: int = 0 486 #: The live verdict, or ``None`` where none has ever been produced. 487 #: 488 #: ⚠️ **A verdict beside a ``waiting`` or ``queued`` status is the 489 #: normal case, not a contradiction**: a team that saves a revision 490 #: after being graded is back in the queue while the previous 491 #: judgement is still the one the board reads. 492 #: ``grade.report_version`` against ``version`` is what says so. 493 grade: ReportGradeSummary | None = None 494 #: When this team handed this range in — 交卷 — or ``None``. 495 #: 496 #: ⚠️ **A different fact from every status here**, and the one an 497 #: organiser at the deadline is actually reading for: ``waiting`` 498 #: covers both "still typing" and "finished ten minutes ago", which 499 #: are opposite answers to "is this team done?". 500 finalized_at: datetime | None = None
One team's report status on the organiser's roster.
⚠️ No body. An event accumulates reports all week and a table of
"who has written one" must not read every one of them out of SQLite
to render a row count — the same argument that made
list_patch_submissions_admin name its columns rather than select
the entity. The body is one route further in, one team at a time.
19class ReportSaveRequest(CtfyModel): 20 """A new version of the caller's team report. 21 22 ⚠️ Deliberately **not** a patch/append shape. A report is one 23 document the team owns; sending the whole thing each time is what 24 makes "the current report" unambiguous, and what lets the server 25 keep every version without having to replay a chain of edits to 26 reconstruct any of them. 27 """ 28 29 content: str
A new version of the caller's team report.
⚠️ Deliberately not a patch/append shape. A report is one document the team owns; sending the whole thing each time is what makes "the current report" unambiguous, and what lets the server keep every version without having to replay a chain of edits to reconstruct any of them.
102class ReportSaveResponse(CtfyModel): 103 """What one save did — the version it wrote, and what it credited. 104 105 ⚠️ **Its own model rather than a field on :class:`ReportInfo`.** 106 "How many findings did this save credit" is a fact about *this 107 write*; on a read, or on a row in the version history, the same field 108 would have to default to zero, which reads as "nothing here was worth 109 anything" about versions that may have been worth a great deal. 110 """ 111 112 report: ReportInfo 113 #: Sentinels this save credited that the team did not already hold. 114 #: 115 #: This is the whole feedback channel the exercise format allows: a 116 #: player learns the last edit was worth something, and never learns 117 #: how many sentinels exist or how many remain 118 #: (``docs/gauntlet-exercise-plan.md`` §4). 119 newly_credited: int = 0 120 #: Where this save leaves the report with the report grader. 121 #: 122 #: ⚠️ **On the save rather than on :class:`ReportInfo`**, for the 123 #: reason ``newly_credited`` is: a queue standing is true of the 124 #: *current* report, and a row in the version history has none — a 125 #: field there would have to default to something, and every value 126 #: it could default to is a claim about a document that has been 127 #: superseded. 128 #: 129 #: A save always lands here as ``waiting`` in an exercise 130 #: competition (the save is what restarts the quiet window) and as 131 #: ``not_scored`` everywhere else, which is exactly the sentence a 132 #: player needs after pressing the button. 133 status: ReportGradeStatus = "not_scored"
What one save did — the version it wrote, and what it credited.
⚠️ Its own model rather than a field on ReportInfo.
"How many findings did this save credit" is a fact about this
write; on a read, or on a row in the version history, the same field
would have to default to zero, which reads as "nothing here was worth
anything" about versions that may have been worth a great deal.
72class ReportStanding(CtfyModel): 73 """Where one range's report stands — what every verb on it reads back. 74 75 ⚠️ **Read back from the store, never composed from the request.** 76 ``admin_awd._info``'s rule: a reply assembled out of what the caller 77 asked for says the write happened whether or not it did, and the two 78 facts here that a client cannot recompute — the queue position and 79 what is left of the grading quota — are exactly the ones a hopeful 80 reply would get wrong. 81 """ 82 83 report: ReportInfo 84 #: Where the current version stands with the report grader. 85 status: ReportGradeStatus = "not_scored" 86 #: 1-based place in the grading queue, ``0`` unless ``queued`` — and 87 #: ``0`` in a silent competition too, which ``status`` tells apart. 88 queue_position: int = 0 89 #: The competition's whole backlog; ``0`` when silent. 90 queue_length: int = 0 91 #: How many times this team has pushed this range's report past the 92 #: quiet window by hand. 93 grade_requests_used: int = 0 94 #: The ceiling on that. ⚠️ **``0`` is unlimited**, the reading every 95 #: other quota on this platform takes — so a client must compare 96 #: against it rather than treating it as "none left", which is the 97 #: reading that greys out the button on the deployments that ration 98 #: nothing. 99 grade_requests_limit: int = 0
Where one range's report stands — what every verb on it reads back.
⚠️ Read back from the store, never composed from the request.
admin_awd._info's rule: a reply assembled out of what the caller
asked for says the write happened whether or not it did, and the two
facts here that a client cannot recompute — the queue position and
what is left of the grading quota — are exactly the ones a hopeful
reply would get wrong.
136class ReportVersionsResponse(CtfyModel): 137 """Every version a team has saved, newest first. 138 139 ⚠️ Carries the bodies. That is right for one team's own history and 140 for an organiser reading one team, and wrong for a competition-wide 141 roster — which is why :class:`ReportRosterRow` exists rather than 142 this being reused there. 143 """ 144 145 competition_id: str = "" 146 team_id: str = "" 147 challenge_id: str = "" 148 versions: list[ReportInfo] = Field(default_factory=list)
Every version a team has saved, newest first.
⚠️ Carries the bodies. That is right for one team's own history and
for an organiser reading one team, and wrong for a competition-wide
roster — which is why ReportRosterRow exists rather than
this being reused there.
349class RevealedAnswer(CtfyModel): 350 """One minted answer, as an organiser is shown it.""" 351 352 question_id: str 353 value: str
One minted answer, as an organiser is shown it.
281class ReviewDecisionRequest(CtfyModel): 282 """Body for the organiser's approve / reject on one team.""" 283 284 status: Literal["approved", "rejected", "pending"] 285 # Mandatory on rejection: a rejected team is told why, and "no reason 286 # given" turns into an organiser support ticket every time. 287 note: str = Field(default="", max_length=2000) 288 289 @field_validator("note") 290 @classmethod 291 def _note_required_on_reject(cls, v: str, info) -> str: 292 if info.data.get("status") == "rejected" and not v.strip(): 293 raise ValueError("a note explaining the rejection is required") 294 return v
Body for the organiser's approve / reject on one team.
1330class RunTranscriptRequest(CtfyModel): 1331 """Publish this run's agent's full conversation, or take it down. 1332 1333 ``RunWallRequest``'s reasoning exactly — required, no default, 1334 because *publish* asserts consent and *withdraw* undoes it and there 1335 is no safe third state. A separate request from the wall one because 1336 it is a separate, stronger consent. 1337 """ 1338 1339 published: bool
Publish this run's agent's full conversation, or take it down.
RunWallRequest's reasoning exactly — required, no default,
because publish asserts consent and withdraw undoes it and there
is no safe third state. A separate request from the wall one because
it is a separate, stronger consent.
1316class RunWallRequest(CtfyModel): 1317 """Put this run's agent on the event's wall, or take it off. 1318 1319 ⚠️ **Required, with no default.** A body that omitted it would have 1320 to mean one of the two, and *publish* is a claim about somebody's 1321 consent while *withdraw* silently undoes a decision they made — so 1322 neither is safe to assume, and there is nothing a third state could 1323 say. ``TokenScopeUpdate``'s three-valued fields exist because a 1324 PATCH has a *leave alone*; this is one switch and a POST. 1325 """ 1326 1327 published: bool
Put this run's agent on the event's wall, or take it off.
⚠️ Required, with no default. A body that omitted it would have
to mean one of the two, and publish is a claim about somebody's
consent while withdraw silently undoes a decision they made — so
neither is safe to assume, and there is nothing a third state could
say. TokenScopeUpdate's three-valued fields exist because a
PATCH has a leave alone; this is one switch and a POST.
1185class RunWriteup(CtfyModel): 1186 """What an agent said it did, in its own words. 1187 1188 ⚠️ **Rendered as plain text, never markdown and never HTML.** The 1189 same rule :class:`LlmCallBody` states, for a stronger reason: this 1190 prose was composed by an image the platform did not build, and 1191 ``CTFY-0001`` invites it to write markdown, so the temptation to 1192 render it is the whole risk. 1193 1194 ⚠️ **Not graded, and the model says so where a reader will see it.** 1195 Scoring rides the agent's own ``POST /submissions``; a write-up is 1196 telemetry beside the score, never a second grading path. 1197 """ 1198 1199 run_id: str = "" 1200 text: str = ""
What an agent said it did, in its own words.
⚠️ Rendered as plain text, never markdown and never HTML. The
same rule LlmCallBody states, for a stronger reason: this
prose was composed by an image the platform did not build, and
CTFY-0001 invites it to write markdown, so the temptation to
render it is the whole risk.
⚠️ Not graded, and the model says so where a reader will see it.
Scoring rides the agent's own POST /submissions; a write-up is
telemetry beside the score, never a second grading path.
1127class ScheduledJobInfo(CtfyModel): 1128 """One recurring job on ``GET /admin/scheduled-jobs``: a projection of 1129 ``ScheduledJobState`` plus the code-side ``default_interval_s`` so the 1130 UI can show "(default: 30s)" next to a tuned value.""" 1131 1132 name: str = "" 1133 enabled: bool = True 1134 interval_s: int = 0 1135 default_interval_s: int = 0 1136 last_run_at_ts: float = 0.0 1137 last_status: JobRunStatus = "" 1138 last_summary: str = "" 1139 last_error: str = "" 1140 last_duration_s: float = 0.0 1141 run_count: int = 0 1142 updated_by_user_id: str = ""
One recurring job on GET /admin/scheduled-jobs: a projection of
ScheduledJobState plus the code-side default_interval_s so the
UI can show "(default: 30s)" next to a tuned value.
1145class ScheduledJobPatch(CtfyModel): 1146 """Body of ``PATCH /admin/scheduled-jobs/{name}``. Either field may be 1147 omitted; ``interval_s`` is clamped to ``[5, 86400]`` by the route.""" 1148 1149 enabled: bool | None = None 1150 interval_s: int | None = None
Body of PATCH /admin/scheduled-jobs/{name}. Either field may be
omitted; interval_s is clamped to [5, 86400] by the route.
416class ScopeCatalogEntry(CtfyModel): 417 """One assignable permission category, for the token-creation UI.""" 418 419 key: str 420 label: str 421 description: str 422 competition_scoped: bool 423 #: Highest level a user may grant for this category. 424 #: 425 #: ⚠️ **The wire was the loose link in a chain of three.** 426 #: ``token_scopes.ScopeCategoryInfo.max_level`` is typed 427 #: ``PermissionLevel``, this copy of it was ``str`` with the members 428 #: in a comment, and ``frontend/src/api.ts`` then declared the same 429 #: field ``PermissionLevel`` again — so the client's stricter claim 430 #: rested on a promise the middle layer had dropped. It is also the 431 #: value ``clamp_permissions`` indexes its rank table with, which is 432 #: where an unlisted level becomes a ``KeyError``. 433 max_level: PermissionLevel
One assignable permission category, for the token-creation UI.
1352class ScoreBucket(CtfyModel): 1353 """One bucket of the scoreboard score-distribution histogram.""" 1354 1355 label: str 1356 count: int = 0
One bucket of the scoreboard score-distribution histogram.
1242class ScoreHistoryPoint(CtfyModel): 1243 """One sample on a team's score/rank-over-time curve. 1244 1245 Both metrics travel together so the frontend can toggle 1246 Score ⇄ Rank without a refetch. 1247 """ 1248 1249 ts: float = 0.0 # Unix seconds (solve time of the driving event) 1250 score: int = 0 # Cumulative flags captured up to ``ts`` 1251 rank: int = 0 # 1-based rank among scoring teams at ``ts``
One sample on a team's score/rank-over-time curve.
Both metrics travel together so the frontend can toggle Score ⇄ Rank without a refetch.
1254class ScoreHistorySeries(CtfyModel): 1255 team_id: str 1256 team_name: str 1257 points: list[ScoreHistoryPoint] = Field(default_factory=list) 1258 #: See :attr:`ScoreboardEntry.anonymous`. The curve keeps its shape 1259 #: under the alias, which is what the alias being stable buys. 1260 anonymous: bool = False
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
1101class ScoreboardEntry(CtfyModel): 1102 rank: int = 0 1103 team_id: str 1104 team_name: str 1105 # Challenges where the team captured every declared flag. Preserved 1106 # for parity with the legacy single-flag scoreboard. 1107 solved: int = 0 1108 # Total individual flags captured across all challenges. Primary 1109 # scoreboard metric now that multi-flag challenges exist — rewards 1110 # partial progress (e.g. foothold without root). 1111 flags_solved: int = 0 1112 # The number this board is ranked by, as decided by the 1113 # competition's scoring rule (``ctfy/server/scoring/``). Under the 1114 # default ``flag_count`` rule it equals ``flags_solved``; under a 1115 # points rule it is the point total. Kept separate from 1116 # ``flags_solved`` so the UI can show both "how much progress" and 1117 # "how many points" without knowing which rule produced the row. 1118 score: int = 0 1119 attempts: int = 0 1120 last_solve_at: datetime | None = None 1121 # Most recent activity of any kind. Distinct from ``last_solve_at`` — 1122 # a team may be actively attempting without a solve, and the UI 1123 # wants to show that they're not idle. 1124 last_active_at: datetime | None = None 1125 # OAuth provider avatar URL for the team's earliest identity that 1126 # supplies one. Empty for password-only teams; the frontend falls 1127 # back to a generated initials avatar in that case. 1128 # Whether a person or an agent is behind this entry. Both compete on 1129 # the same board — a user enters, and a user's agent enters — so a 1130 # board that does not say which is which is one whose ranking cannot 1131 # be read. 1132 # 1133 # ⚠️ A display fact, never a ranking one. No rule scores on it: an 1134 # agent and a human are ranked by what they solved, and separating 1135 # them is the reader's choice to make from a labelled board. 1136 entrant_kind: EntrantKind = "human" 1137 # Whose agent this is, when it is somebody's. ``None`` for a person 1138 # **and** for the platform's own eval entrant — see 1139 # :class:`AgentEntrantInfo`. 1140 # 1141 # ⚠️ Attached after the scoring rule has produced the row, never 1142 # carried through :class:`TeamFacts`. A rule reads only the facts it 1143 # names, so keeping this out of them is what makes "no rule can rank 1144 # on who built the agent" structural rather than a convention four 1145 # rule implementations have to keep. 1146 agent: AgentEntrantInfo | None = None 1147 avatar_url: str = "" 1148 #: The institution **this squad** publishes, or ``None`` — see 1149 #: :attr:`ctfy.server.models.TeamInfo.affiliation`, which is the 1150 #: same fact about the same subject. 1151 #: 1152 #: ⚠️ **A display fact, never a ranking one**, exactly like 1153 #: ``entrant_kind`` and ``agent``: it is attached after the scoring 1154 #: rule has produced the row and never carried through 1155 #: :class:`TeamFacts`, so no rule can rank on where a team studies. 1156 #: 1157 #: ⚠️ **Cleared by ``_board_fields``**, with the avatar and the 1158 #: agent attribution: an institution beside an alias is stronger 1159 #: identity than the name it replaced. 1160 affiliation: AffiliationInfo | None = None 1161 # The AWD+ split of ``score``. Zero on every other format, where the 1162 # board renders one number and these columns stay hidden. 1163 # 1164 # Both are carried even though ``attack + defence == score``, because 1165 # the point of the split *is* the breakdown: a single total never 1166 # explains why a team that solved less is ranked higher. Deriving one 1167 # from the other in the client would put the format's arithmetic in 1168 # two places. 1169 # ⚠️ **Whether the identity on this row is withheld, never whether 1170 # the entrant asked for it.** The client needs to know to render the 1171 # localised word beside the alias and to stop linking; it must not be 1172 # told anything about who is behind it. Defaults ``False``, which is 1173 # what every producer that predates the field means and what an 1174 # older server's rows read as — the safe direction, since a wrong 1175 # ``True`` merely labels a visible team "anonymous" while a wrong 1176 # ``False`` publishes nothing it did not already publish (the name it 1177 # would print is the alias either way). 1178 anonymous: bool = False 1179 attack_score: int = 0 1180 defence_score: int = 0 1181 # Challenges this team holds a ``fixed`` defence verdict on. A count, 1182 # not the ids — the board is a ranking, and the ids are one click 1183 # away on the team's own page. 1184 defended: int = 0 1185 # The exercise-format split of ``score``. Zero on every other format, 1186 # for the same reason and with the same shape as the AWD+ pair above. 1187 # 1188 # ⚠️ Deliberately **not** a reuse of ``attack_score`` / 1189 # ``defence_score``. The numbers happen to compose the same way and 1190 # they are different facts: reusing the AWD names would put "how well 1191 # this team wrote it up" in a field every reader has been told means 1192 # "patches that survived the exploit". A wire field is a claim about 1193 # what the number *is*. 1194 # 1195 # ``reporting_score`` is the graded half weighted onto the capability 1196 # scale, so it is what the board ranked by — the grader's own 1197 # unweighted total stays on ``ExerciseReportGradeState.points``, 1198 # which is the record an appeal argues from. 1199 capability_score: int = 0 1200 reporting_score: int = 0 1201 # The peer-authoring split of ``score``. Zero on every other 1202 # format, same shape and same reason as the two pairs above. 1203 # 1204 # ⚠️ **Its own field rather than a reuse of ``reporting_score``**, 1205 # for the reason stated there about the AWD pair: the numbers 1206 # compose the same way and they are different facts, and a wire 1207 # field is a claim about what the number *is*. "How the field fared 1208 # against the challenges you wrote" in a field readers have been 1209 # told means "how well you wrote up an engagement" is a wrong 1210 # sentence rendered from correct arithmetic. 1211 # 1212 # ⚠️ It is the **weighted** term — what the board ranked by — for 1213 # `reporting_score`'s reason: the unweighted per-challenge curve 1214 # values are not stored anywhere a dispute could argue from, so this 1215 # number has to be the one the ranking used rather than a second 1216 # rendering of it. The solving half sits on ``capability_score``, 1217 # which this format reuses verbatim. 1218 authoring_score: int = 0 1219 # The team's eligibility verdict, and **only when the viewer is 1220 # entitled to it** — a competition admin or reviewer. Everywhere else 1221 # this is ``""``. 1222 # 1223 # ``""`` means "not disclosed", never "approved". Defaulting it to 1224 # ``approved`` would have been the natural-looking choice and is the 1225 # bug: a public board would then assert that every team passed 1226 # review, which is a stronger claim than the platform is entitled to 1227 # make and is false during the review backlog. 1228 # 1229 # Not public because no other surface publishes it either — the 1230 # verdict reaches the team's own members and the organisers, and 1231 # nobody else. Putting "rejected" next to a team name on a page the 1232 # whole event reads is a different act from recording it on the 1233 # review queue. 1234 # 1235 # It never affects ``rank``: review is an eligibility marker, not a 1236 # gate, and an unapproved team plays and places exactly as it would 1237 # have. ``TestReviewIsNotAGate`` pins that; this field is what makes 1238 # the fact *visible* to the organiser without changing it. 1239 review_status: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
148class SeriesOccurrencePreview(CtfyModel): 149 """One occurrence a series *would* create, without creating it.""" 150 151 occurrence_at: AwareDatetime 152 title: str = "" 153 challenge_ids: list[str] = Field(default_factory=list) 154 #: Empty when this occurrence could be filled. Otherwise why not — 155 #: the pool ran dry, or names challenges the catalog no longer has. 156 refused: str = ""
One occurrence a series would create, without creating it.
159class SeriesPreview(CtfyModel): 160 """The dry run. 161 162 Its own route rather than a flag on the save, for the same reason 163 `GET /awd/plan` is: the answer is what decides whether to commit. A 164 weekly's first real feedback is otherwise a week away, and the 165 mistakes it catches — wrong weekday, wrong timezone, a pool that 166 covers three weeks of a schedule meant to run indefinitely — are all 167 invisible in the form. 168 169 ⚠️ It projects **past the lead window** on purpose. `plan_spawns` 170 answers "what should exist now", which for a freshly-saved series is 171 usually one contest; an organiser needs to see the *fourth* week to 172 know the pool runs out. 173 """ 174 175 series_id: str = "" 176 occurrences: list[SeriesOccurrencePreview] = Field(default_factory=list) 177 #: Occurrences already past, reported for the same reason the job 178 #: reports them: a schedule whose anchor is in the past is a common 179 #: mistake and silently produces nothing. 180 missed: list[AwareDatetime] = Field(default_factory=list)
The dry run.
Its own route rather than a flag on the save, for the same reason
GET /awd/plan is: the answer is what decides whether to commit. A
weekly's first real feedback is otherwise a week away, and the
mistakes it catches — wrong weekday, wrong timezone, a pool that
covers three weeks of a schedule meant to run indefinitely — are all
invisible in the form.
⚠️ It projects past the lead window on purpose. plan_spawns
answers "what should exist now", which for a freshly-saved series is
usually one contest; an organiser needs to see the fourth week to
know the pool runs out.
287class SetPasswordRequest(CtfyModel): 288 # Required iff the caller already has a password identity — absent on 289 # the first-time "attach password to an OAuth account" flow. 290 current_password: str = Field(default="", max_length=256) 291 # See RegisterRequest.password for the length-minimum rationale. 292 new_password: str = Field(min_length=12, max_length=256)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
526class ShellRecordingInfo(CtfyModel): 527 """One row from ``GET /admin/shell-recordings``: a per-session 528 asciinema v2 cast file the platform wrote when a super-admin 529 opened a docker-exec shell. The frontend renders a playable 530 inline player; the raw bytes are pulled via the per-session 531 download route (also super-admin gated, also audited).""" 532 533 session_id: str = "" 534 path: str = "" 535 size_bytes: int = 0 536 modified_at_ts: float = 0.0
One row from GET /admin/shell-recordings: a per-session
asciinema v2 cast file the platform wrote when a super-admin
opened a docker-exec shell. The frontend renders a playable
inline player; the raw bytes are pulled via the per-session
download route (also super-admin gated, also audited).
672class SshCredential(CtfyModel): 673 """Everything a defender needs to `ssh` into their own box, once. 674 675 Model (b): the platform mints the keypair rather than a player 676 registering one, so both halves are here. The private key is 677 returned exactly once and never stored — there is nothing to leak 678 later and nothing to revoke, because it dies with the certificate's 679 five-minute window. 680 681 ⚠️ **The username is not cosmetic.** The bastion reads the subject 682 back off the login name, and asyncssh refuses a certificate whose 683 signed principal list omits it — so a client that connects under any 684 other name is rejected at the handshake with nothing in the response 685 explaining why. 686 """ 687 688 #: Which transport this credential is for — see ``AwdShellExposure``. 689 #: ``managed`` (the default) is the cert/bastion shape below; ``open`` 690 #: is the classic-AWD box-local ``sshd``, where the fields that carry 691 #: are ``username`` / ``password`` / ``host`` / ``port`` and the cert 692 #: pair is empty. A client branches on this rather than guessing from 693 #: which fields are set (#1725). 694 exposure: str = AwdShellExposure.MANAGED.value 695 #: OpenSSH private key (``managed`` only). Returned once; the platform 696 #: keeps no copy. ``""`` under ``open`` exposure. 697 private_key: str = "" 698 #: The certificate over it, signed by the platform's SSH CA 699 #: (``managed`` only). ``""`` under ``open`` exposure. 700 certificate: str = "" 701 #: The login name. ``user_id:instance_id`` under ``managed`` (the 702 #: bastion reads the subject off it); the box's login user (``root``) 703 #: under ``open``. 704 username: str 705 #: The starting password (``open`` exposure only). ``""`` under 706 #: ``managed``, which authenticates with the cert. The owner rotates 707 #: it with ``appctl passwd`` after logging in. 708 password: str = "" 709 #: Where to present it. Under ``managed`` this is the bastion, which 710 #: only the operator knows how to publish. Under ``open`` it is the 711 #: box's own arena address, reached over the caller's arena tunnel. 712 host: str 713 port: int 714 #: Seconds the credential stays useful for *connecting*. Under 715 #: ``managed`` it is the certificate's short life; under ``open`` the 716 #: password lasts the match (until the defender rotates it), so this 717 #: is the match's remaining seconds as an advisory, not a hard expiry. 718 expires_in: int
Everything a defender needs to ssh into their own box, once.
Model (b): the platform mints the keypair rather than a player registering one, so both halves are here. The private key is returned exactly once and never stored — there is nothing to leak later and nothing to revoke, because it dies with the certificate's five-minute window.
⚠️ The username is not cosmetic. The bastion reads the subject back off the login name, and asyncssh refuses a certificate whose signed principal list omits it — so a client that connects under any other name is rejected at the handshake with nothing in the response explaining why.
652class SshExchangeRequest(CtfyModel): 653 """The subject a bastion has already authenticated by certificate. 654 655 ⚠️ **Not the certificate.** That was the first shape and it cannot 656 be built: asyncssh discards the client certificate once it has 657 validated it, so the bastion has nothing to forward. What survives 658 the handshake is the login name, which the CA signs — a certificate 659 whose principal list omits the requested username is refused — so 660 the bastion knows the subject but cannot re-prove it with the 661 credential. The bearer on this request is what makes the claim 662 trustworthy; see ``routes/ssh_exchange.py``. 663 """ 664 665 #: Who the certificate named. Authorisation still runs against them. 666 user_id: str 667 #: Which box. Re-resolved here — it can stop or change hands between 668 #: the certificate mint and the connect. 669 instance_id: str
The subject a bastion has already authenticated by certificate.
⚠️ Not the certificate. That was the first shape and it cannot
be built: asyncssh discards the client certificate once it has
validated it, so the bastion has nothing to forward. What survives
the handshake is the login name, which the CA signs — a certificate
whose principal list omits the requested username is refused — so
the bastion knows the subject but cannot re-prove it with the
credential. The bearer on this request is what makes the claim
trustworthy; see routes/ssh_exchange.py.
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.
537class StartRequest(CtfyModel): 538 challenge_id: str 539 # Per-instance TTL override in seconds. ``None`` (the default) 540 # defers to the platform's ``default_instance_ttl_s`` setting — 541 # admin-tunable, currently 24h. Explicit values are clamped to 542 # ``max_instance_ttl_s`` so a misbehaving SDK / agent can't pin 543 # a node by requesting an arbitrarily large TTL. 544 ttl: int | None = None 545 # Which competition this instance is for. The platform looks up 546 # the caller's per-comp team via ``(user_id, competition_id)`` and 547 # stamps the instance with that team. Optional only when the 548 # caller is on exactly one team — multi-comp users always pass 549 # it explicitly. 550 competition_id: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
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.
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.
887class SubmissionCreate(CtfyModel): 888 # ``instance_id`` is the canonical handle since 0.2: the server 889 # reads challenge_id and competition_id off the instance row, so 890 # callers don't need to repeat them and N instances per 891 # (team, challenge) are unambiguous on submit. 892 instance_id: str 893 # Which question on the challenge the agent is answering. Defaults 894 # to ``"flag"`` so single-question challenges stay one-liner submits; 895 # multi-question challenges (see ``ChallengeInfo.questions``) must 896 # specify the id explicitly. 897 question_id: str = "flag" 898 # The agent's submission. String for ``dynamic`` / ``static`` / 899 # ``single_select`` questions; list of strings for ``multi_select`` 900 # (e.g. ``["idor", "ssrf"]``). The server picks the grader by 901 # consulting ``spec.questions[i].mode`` on the live instance. 902 answer: str | list[str]
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
905class SubmissionResponse(CtfyModel): 906 id: str 907 correct: bool 908 solve_time_s: float = 0 # seconds from instance start to correct submission 909 # 1 = first blood, 2 = second, 3 = third, 4+ = regular solve. 910 # 0 when incorrect or when the team already had a prior correct solve. 911 # Ranks within the specific question — independent of the 912 # full-challenge solve rank. 913 solve_rank: int = 0 914 # The question id this submission targeted. Echoed back to the 915 # caller so simple submission UIs don't have to thread the id 916 # separately. Equal to the request's ``question_id``. 917 question_id: str | None = None 918 # True iff this team has now captured every question declared on 919 # the challenge. Single-question challenges: always equals ``correct``. 920 challenge_fully_solved: bool = False 921 # Remaining wrong-attempt budget for the question this submission 922 # targeted, AFTER this attempt is counted (the cap is on wrongs; 923 # correct submissions don't consume budget). ``None`` when the 924 # question's mode is uncapped (dynamic free-form, or any mode the 925 # operator opted out of via ``question_attempt_caps``). The UI 926 # uses this to decrement its remaining-attempts badge without 927 # re-fetching the instance — see ``ctfy.server.submission_policy``. 928 attempts_remaining: int | None = None 929 # Whether the team's capture of this question is on the 930 # competition's board. Only meaningful when ``correct``. 931 # 932 # ⚠️ **Defaults to False on purpose**, which is the loud direction. 933 # A competition accepts submissions outside its play window and 934 # simply does not count them (only a competition declaring 935 # ``sessions`` refuses), so before this field the player got 936 # ``correct: true`` and scored nothing, with nothing anywhere 937 # saying why. A producer that forgets to set this makes every solve 938 # claim it did not count — visible in a minute; the other default 939 # restores exactly the silence this exists to end. 940 ranked: bool = False 941 # Why it is not on the board, when it is not. 942 # 943 # ⚠️ **A refinement of ``ranked``, never a second answer to it.** A 944 # client gates on ``ranked``; this only decides *which sentence* it 945 # then shows, because the two members' repairs are opposite — 946 # ``outside_window`` says come back while the competition is open, 947 # and that copy under ``own_authored`` is advice that can never pay 948 # off. ``""`` names no reason at all, which is what an older server 949 # and a forgetful producer both say. 950 unranked_reason: UnrankedReason = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
452class TagStat(CtfyModel): 453 """One axis on the per-tag strength radar. 454 455 ``solved`` is the team's solve count for challenges carrying this 456 tag; ``total`` is the platform-wide challenge count for the tag, 457 so the frontend can render "team / total" or normalize to a 0–1 458 ratio for the radar polygon.""" 459 460 tag: str = "" 461 solved: int = 0 462 total: int = 0
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.
124class TeamAffiliationSelection(CtfyModel): 125 """A squad's badge: the choice, its effect, and what it could be. 126 127 ⚠️ **Three fields because two of them can disagree, and the 128 disagreement is the state a captain needs to see.** 129 :attr:`domain` is what the team chose and :attr:`affiliation` what 130 strangers actually see — a badge whose backing has gone (the member 131 who published it left, stopped publishing, or the operator removed 132 the catalogue row) reads as *chosen, showing nothing*, which a model 133 holding only the badge cannot tell from *nothing chosen* and which 134 has a different repair. `AffiliationSelection`'s decision on the 135 personal badge, for the same reason. 136 137 ⚠️ **:attr:`choices` is on the answer rather than a second route**, 138 because the two are one question with one cost: resolving the badge 139 already reads every member's published institution, so a picker 140 fetched separately would pay for that walk twice and could show a 141 captain an option the write then refuses. 142 143 ⚠️ **Published, never merely proved.** ``choices`` holds only what 144 team-mates have chosen to show on their own profiles — see 145 `ctfy.server.affiliations.team_candidates`. A squad whose members 146 all stay private can claim nothing, which is the correct answer 147 rather than a gap. 148 """ 149 150 #: The domain the captain chose. ``""`` is *no badge*, the default 151 #: and the opt-out. 152 domain: str = "" 153 #: What that choice resolves to right now, or ``None``. 154 affiliation: AffiliationInfo | None = None 155 #: Every institution at least one current member publishes — the 156 #: set the choice is checked against on every read, which is why it 157 #: is the same set the picker offers. 158 choices: list[AffiliationInfo] = Field(default_factory=list)
A squad's badge: the choice, its effect, and what it could be.
⚠️ Three fields because two of them can disagree, and the
disagreement is the state a captain needs to see.
domain is what the team chose and affiliation what
strangers actually see — a badge whose backing has gone (the member
who published it left, stopped publishing, or the operator removed
the catalogue row) reads as chosen, showing nothing, which a model
holding only the badge cannot tell from nothing chosen and which
has a different repair. AffiliationSelection's decision on the
personal badge, for the same reason.
⚠️ choices is on the answer rather than a second route,
because the two are one question with one cost: resolving the badge
already reads every member's published institution, so a picker
fetched separately would pay for that walk twice and could show a
captain an option the write then refuses.
⚠️ Published, never merely proved. choices holds only what
team-mates have chosen to show on their own profiles — see
ctfy.server.affiliations.team_candidates. A squad whose members
all stay private can claim nothing, which is the correct answer
rather than a gap.
377class TeamCaptainRequest(CtfyModel): 378 """Body for an organiser handing a team to one of its members.""" 379 380 # The member to promote. Must already be on the team — promoting an 381 # outsider would be a join and a transfer in one call, which is how 382 # a squad ends up with a captain nobody on it recognises. 383 user_id: str = Field(min_length=1)
Body for an organiser handing a team to one of its members.
104class TeamDetail(TeamInfo): 105 """``GET /teams/{id}`` payload — adds the resolved members list.""" 106 107 members: list[TeamMemberInfo] = Field(default_factory=list)
GET /teams/{id} payload — adds the resolved members list.
334class TeamDisqualifyRequest(CtfyModel): 335 """Body for an organiser removing a team from a competition.""" 336 337 # Mandatory, and with a floor rather than merely non-empty. This is 338 # the row an appeal is argued from and the text the team is shown 339 # when their next submission is refused; "cheating" tells them 340 # nothing they can answer. Same reasoning as the admin shell's 341 # mandatory ≥10-char reason, which exists for the same audience. 342 reason: str = Field(min_length=10, max_length=2000)
Body for an organiser removing a team from a competition.
21class TeamInfo(CtfyModel): 22 """A team's public-facing summary. 23 24 Post user/team-split a team is a pure container — no email, no 25 role, no human profile fields. ``solves`` and ``attempts`` aggregate 26 across every member's contribution while they were on this team. 27 """ 28 29 id: str 30 name: str 31 description: str = "" 32 captain_user_id: str = "" 33 captain_display_name: str = "" 34 captain_avatar_url: str = "" 35 member_count: int = 0 36 created_at: datetime | None = None 37 solves: int = 0 38 attempts: int = 0 39 last_active_at: datetime | None = None 40 # Per-competition scoping — every team belongs to exactly one 41 # competition. 42 competition_id: str = "" 43 #: See :attr:`ctfy.server.models.ScoreboardEntry.anonymous`. 44 #: 45 #: ⚠️ ``solves`` / ``attempts`` / ``member_count`` stay on a withheld 46 #: row and are not the leak they look like. The de-anonymising join 47 #: this route made possible was *name × solve count* against the 48 #: board; with the name withheld on both sides there is nothing to 49 #: join to, and a bare progress count names nobody. Withholding them 50 #: as well would make an anonymous entrant's card a row of dashes 51 #: for no gain. 52 anonymous: bool = False 53 #: The institution **this squad** publishes, or ``None``. 54 #: 55 #: ⚠️ **The team's, never a member's, and the field name is the 56 #: whole hazard.** A person's badge is 57 #: :attr:`ctfy.server.models.UserInfo.affiliation` and derives from 58 #: ``users.affiliation_email``; this one derives from 59 #: ``teams.affiliation_domain``. Two subjects, one word — so a 60 #: surface that renders "the affiliation" without saying whose is 61 #: how a captain's university ends up printed against a squad that 62 #: never chose it. :data:`ctfy.server.models.TeamMemberInfo` carries 63 #: none for exactly that reason. 64 #: 65 #: ⚠️ **Composed, never stored resolved**, the same as the personal 66 #: badge: the domain is on the row and the institution is looked up 67 #: on every read, so the badge vanishes when the operator deletes 68 #: the catalogue entry or the last member who published it leaves. 69 #: 70 #: ⚠️ **Cleared under an alias.** An institution beside a 71 #: pseudonym is stronger identity than the name it replaced — "the 72 #: Tsinghua entrant" narrows a field of two hundred to a handful — 73 #: so `ctfy.server.anonymity` blanks it wherever it blanks the name; 74 #: `test_the_badge_never_reaches_an_alias` drives every applier that 75 #: reaches this model and asserts so. 76 affiliation: AffiliationInfo | None = None
A team's public-facing summary.
Post user/team-split a team is a pure container — no email, no
role, no human profile fields. solves and attempts aggregate
across every member's contribution while they were on this team.
308class TeamInviteInfo(CtfyModel): 309 """Public projection of a :class:`TeamInviteState`. 310 311 Surfaces on captain CRUD. ``active`` is server-derived from the 312 expires/use/revoked triple. ``kind`` discriminates code-style 313 invites from directed invites/requests; ``target_user_id`` is 314 the recipient for ``kind="direct"`` (set at mint time) and the 315 requester for ``kind="join_request"`` (set when a member opens 316 a request). 317 """ 318 319 id: str 320 code: str 321 team_id: str 322 created_by_user_id: str 323 created_at: datetime | None = None 324 expires_at: datetime | None = None 325 max_uses: int 326 use_count: int 327 revoked_at: datetime | None = None 328 active: bool = True 329 competition_id: str = "" 330 kind: Literal["code", "direct", "join_request"] = "code" 331 target_user_id: str = ""
Public projection of a TeamInviteState.
Surfaces on captain CRUD. active is server-derived from the
expires/use/revoked triple. kind discriminates code-style
invites from directed invites/requests; target_user_id is
the recipient for kind="direct" (set at mint time) and the
requester for kind="join_request" (set when a member opens
a request).
297class TeamLogoUploadResponse(CtfyModel): 298 """``POST /competitions/{id}/team/logo`` result.""" 299 300 object_key: str 301 content_type: str = "" 302 size_bytes: int = 0
POST /competitions/{id}/team/logo result.
79class TeamMemberInfo(CtfyModel): 80 """One member row on the team-detail page.""" 81 82 user_id: str 83 display_name: str = "" 84 avatar_url: str = "" 85 is_captain: bool = False 86 joined_at: datetime | None = None 87 #: This member's *own* name is withheld — either because they asked 88 #: or because the squad around them is anonymous. See 89 #: :attr:`ctfy.server.models.ScoreboardEntry.anonymous`. 90 anonymous: bool = False 91 92 # ⚠️ **No ``affiliation`` here, and it is a decision rather than an 93 # omission.** A roster row is a *person* inside a *team*, so a badge 94 # on it would be the person's while every alias around it is decided 95 # about the squad: ``withholds_team`` rules on the row this sits in, 96 # ``withholds_user`` on the row itself, and ``_is_own_team`` is a 97 # third predicate that shows a captain their own members' identities. 98 # A per-person attribute one forgotten branch from a withheld squad 99 # is the shape this repository has paid for; the surfaces the badge 100 # was asked for are the team's, and `TeamInfo.affiliation` carries 101 # them. `test_the_badge_never_reaches_an_alias` refuses it by name.
One member row on the team-detail page.
345class TeamStandingInfo(CtfyModel): 346 """Whether a team is currently in the event, and why not. 347 348 A separate model from ``TeamDetail`` on purpose: this is what the 349 two enforcement routes return and what the admin table polls, so it 350 stays small enough to fetch per row without dragging a roster and a 351 logo key along. 352 """ 353 354 id: str 355 name: str 356 competition_id: str = "" 357 disqualified: bool = False 358 disqualified_at: datetime | None = None 359 disqualified_by: str = "" 360 disqualification_reason: str = "" 361 362 @classmethod 363 def from_team(cls, team: TeamState) -> TeamStandingInfo: 364 return cls( 365 id=team.id, 366 name=team.name, 367 competition_id=team.competition_id, 368 # Projected as a plain bool so a client never has to know 369 # that "absent timestamp" is how the platform spells "in". 370 disqualified=team.is_disqualified, 371 disqualified_at=team.disqualified_at, 372 disqualified_by=team.disqualified_by, 373 disqualification_reason=team.disqualification_reason, 374 )
Whether a team is currently in the event, and why not.
A separate model from TeamDetail on purpose: this is what the
two enforcement routes return and what the admin table polls, so it
stays small enough to fetch per row without dragging a roster and a
logo key along.
362 @classmethod 363 def from_team(cls, team: TeamState) -> TeamStandingInfo: 364 return cls( 365 id=team.id, 366 name=team.name, 367 competition_id=team.competition_id, 368 # Projected as a plain bool so a client never has to know 369 # that "absent timestamp" is how the platform spells "in". 370 disqualified=team.is_disqualified, 371 disqualified_at=team.disqualified_at, 372 disqualified_by=team.disqualified_by, 373 disqualification_reason=team.disqualification_reason, 374 )
111class TestEmailRequest(CtfyModel): 112 template: str 113 #: Defaults to the caller's own address. An arbitrary recipient is 114 #: allowed on purpose — checking how a template lands in Outlook or 115 #: QQ mail requires mailing an account on that provider — and every 116 #: send is audited with the address. 117 to: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
120class TestEmailResult(CtfyModel): 121 sent: bool 122 to: str 123 template: str 124 subject: str 125 #: The provider's id for the accepted message, for cross-referencing 126 #: its delivery dashboard. Empty when the send failed, and also 127 #: legitimately empty on success if the response could not be parsed 128 #: — ``sent`` is the outcome, this is only a handle on it. 129 message_id: str = "" 130 #: Provider-side reason when ``sent`` is false. Empty on success. 131 error: str = ""
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
295class TokenInfo(CtfyModel): 296 id: str 297 #: ⚠️ ``agent`` is deprecated and ``0006`` rewrote every row, but it 298 #: stays a member: a legacy dump restored into a new deployment still 299 #: carries it, and a wire model that refuses a value the column can 300 #: hold turns a readable row into a 500. 301 kind: TokenKind 302 label: str = "" 303 created_at: datetime | None = None 304 last_used_at: datetime | None = None 305 # ISO-8601 UTC timestamp; empty means the token never expires. 306 expires_at: datetime | None = None 307 # Client metadata captured when the session was minted. Only populated 308 # for user-kind tokens (browser sessions). 309 ip_address: str = "" 310 user_agent: str = "" 311 # True for the session that made this request — lets the UI show "this 312 # session" and disable the Revoke button so a user can't accidentally 313 # log themselves out. 314 is_current: bool = False 315 # Fine-grained scope summary (empty/"none" for user-kind tokens). 316 competition_access: CompetitionAccessKind = "none" 317 competition_ids: list[str] = Field(default_factory=list) 318 #: Category → level, dumped from :class:`TokenPermissions`, whose 319 #: every field is a ``PermissionLevel``. 320 #: 321 #: ⚠️ **`dict[str, str]` here was the `max_level` shape a third 322 #: time.** The producer is `TokenPermissions.model_dump()`, so the 323 #: values have never been anything but the three-member ladder — 324 #: while the wire published plain `string` and `api.ts` declared 325 #: `Record<string, PermissionLevel>` anyway, i.e. the client 326 #: asserting more than the server promised, about the value every 327 #: scope gate ranks with. Narrowed here so the derivation in 328 #: `@/types` keeps the check rather than losing it. 329 permissions: dict[str, PermissionLevel] = Field(default_factory=dict) 330 331 #: The id of the token this one replaced, or ``""`` for one that was 332 #: minted rather than rotated. 333 #: 334 #: ⚠️ **A column with no reader is an edge nobody walks.** The 335 #: rotation link shipped as ``team_tokens.rotated_from`` with a 336 #: writer and nothing that reads it — so "revoked rows are kept 337 #: precisely so an audit can resolve a ``pf_…`` found in a log" was 338 #: true of the table and unreachable from every surface. This is 339 #: half the repair (the owner's own list); ``POST 340 #: /admin/token-trace`` is the other. 341 #: 342 #: ⚠️ Named for the column and **not** ``rotated_from_id``: the 343 #: ``<entity>_id``-for-a-foreign-key half of the wire-naming rule is 344 #: about a *different entity's* id, and this is the same table — 345 #: ``CompetitionState.virtual_of`` is the shape it follows. 346 rotated_from: str = "" 347 348 #: Whether this token's grants are exactly one of the named 349 #: templates, or ``""`` for a hand-assembled set. 350 #: 351 #: ⚠️ **A ``computed_field``, deliberately not a column.** Which 352 #: template minted a token is a fact that stops being true the 353 #: moment either the token or the catalog is edited; the permission 354 #: map is the authority, so this re-derives from the map every read 355 #: and cannot disagree with it. (``LlmBudgetRow.exhausted`` is the 356 #: same call on a wire model; ``TeamState.is_disqualified`` is the 357 #: opposite one on a state model, where ``model_dump`` feeds 358 #: ``to_orm`` and every dumped key must be a column.) 359 @computed_field 360 @property 361 def template(self) -> TokenTemplateName | Literal[""]: 362 from ctfy.server.token_templates import template_matching 363 364 return template_matching(self.permissions)
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
498class TokenLifetimeRequest(CtfyModel): 499 """Body for ``renew`` and ``rotate`` — the new lifetime, in days. 500 501 ⚠️ **Measured from now, never added to what is left.** Adding to the 502 remaining time lets a chain of renewals walk a credential's expiry 503 arbitrarily far out while every individual step looks like a 504 30-day one — and "expires in 30 days" is the sentence the page 505 prints, so the drift would be invisible on the only surface that 506 reports it. ``None`` takes the server default, ``0`` is *never 507 expires*: the same encoding ``expires_in_days`` already uses at 508 mint, because two spellings of "no expiry" on one axis is how the 509 expiry picker shipped three answers to one question. 510 """ 511 512 expires_in_days: int | None = Field(default=None, ge=0, le=365 * 5)
Body for renew and rotate — the new lifetime, in days.
⚠️ Measured from now, never added to what is left. Adding to the
remaining time lets a chain of renewals walk a credential's expiry
arbitrarily far out while every individual step looks like a
30-day one — and "expires in 30 days" is the sentence the page
prints, so the drift would be invisible on the only surface that
reports it. None takes the server default, 0 is never
expires: the same encoding expires_in_days already uses at
mint, because two spellings of "no expiry" on one axis is how the
expiry picker shipped three answers to one question.
464class TokenScopeUpdate(CtfyModel): 465 """Body for ``PATCH /auth/tokens/{id}`` — edit a live token's reach. 466 467 ⚠️ **A partial update, read through ``model_fields_set``.** Every 468 field defaults to ``None`` and ``None`` means *leave it alone*, so 469 a client that sends only ``permissions`` does not blank the 470 competition scope. Reading the whole model instead is the defect 471 the registration editor already paid for: it blanked five columns 472 and landed an audit row naming every field. 473 474 ⚠️ **``competition_ids`` therefore has two absences that are not the 475 same.** ``None`` is *leave the list alone*; ``[]`` is *clear it*, 476 which is what a caller switching to ``none``/``all`` means. Folding 477 them together makes one of the two unsayable. 478 479 ⚠️ **Not here on purpose**: the secret (that is ``rotate``), the 480 expiry (that is ``renew``) and the kind. Each has its own verb with 481 its own refusals, and a PATCH that quietly did any of them would be 482 a fourth way to reach a decision the other three exist to make 483 deliberate. 484 """ 485 486 label: str | None = Field(default=None, max_length=64) 487 #: Re-seed from a named template. Explicit fields below still win, 488 #: exactly as at the mint. 489 template: TokenTemplateName | None = None 490 competition_access: str | None = None 491 competition_ids: list[str] | None = None 492 #: ``str``-typed for ``CreateFineGrainedTokenRequest.permissions``' 493 #: reason: this is a request and ``clamp_permissions`` exists to 494 #: lower what arrives rather than refuse it. 495 permissions: dict[str, str] | None = None
Body for PATCH /auth/tokens/{id} — edit a live token's reach.
⚠️ A partial update, read through model_fields_set. Every
field defaults to None and None means leave it alone, so
a client that sends only permissions does not blank the
competition scope. Reading the whole model instead is the defect
the registration editor already paid for: it blanked five columns
and landed an audit row naming every field.
⚠️ competition_ids therefore has two absences that are not the
same. None is leave the list alone; [] is clear it,
which is what a caller switching to none/all means. Folding
them together makes one of the two unsayable.
⚠️ Not here on purpose: the secret (that is rotate), the
expiry (that is renew) and the kind. Each has its own verb with
its own refusals, and a PATCH that quietly did any of them would be
a fourth way to reach a decision the other three exist to make
deliberate.
436class TokenScopesResponse(CtfyModel): 437 """``GET /auth/tokens/scopes`` — the catalog the UI renders.""" 438 439 categories: list[ScopeCatalogEntry] = Field(default_factory=list) 440 #: Every assignable level, published so the picker need not hardcode 441 #: the taxonomy — which is what this route's docstring promises. 442 #: 443 #: ⚠️ **Derived from the type, and it was a hand-written default on a 444 #: ``list[str]``.** Two things could drift from each other with 445 #: nothing red: the value here and ``PermissionLevel`` itself, and 446 #: this field's ``str`` against ``frontend/src/api.ts``, which 447 #: already declares the same field ``PermissionLevel[]`` — the wire 448 #: was the looser of the two, so the client's stricter claim rested 449 #: on a promise the server did not make. 450 levels: list[PermissionLevel] = Field(default_factory=lambda: list(get_args(PermissionLevel)))
GET /auth/tokens/scopes — the catalog the UI renders.
453class TokenTemplatesResponse(CtfyModel): 454 """``GET /auth/tokens/templates`` — the named starting points. 455 456 Carries the catalog's own models rather than a copy of them: the 457 route publishes exactly what ``POST /auth/tokens`` will mint from, 458 so the picker cannot show a grant the mint does not make. 459 """ 460 461 templates: list[TokenTemplateInfo] = Field(default_factory=list)
GET /auth/tokens/templates — the named starting points.
Carries the catalog's own models rather than a copy of them: the
route publishes exactly what POST /auth/tokens will mint from,
so the picker cannot show a grant the mint does not make.
528class TokenTraceLink(CtfyModel): 529 """One token in a rotation lineage, as an operator reads it. 530 531 ⚠️ **Nothing derived from the secret is on this model** — no 532 plaintext and no hash. ``AnswerTraceResponse`` publishes 533 ``answer_hash`` because that digest is the correlation key a second 534 surface matches on; a token hash has no such reader, and 535 ``hash_token`` is unsalted SHA-256, so handing it back would publish 536 the exact stored verifier for a credential to every caller of a 537 route whose whole subject is a credential that has already leaked. 538 """ 539 540 id: str 541 kind: TokenKind 542 label: str = "" 543 created_at: datetime | None = None 544 last_used_at: datetime | None = None 545 expires_at: datetime | None = None 546 revoked_at: datetime | None = None 547 rotated_from: str = "" 548 #: The row the submitted secret hashes to. Exactly one link in a 549 #: chain carries it. 550 is_traced: bool = False 551 #: Whether this token would authenticate a request **right now** — 552 #: neither revoked nor expired. 553 #: 554 #: ⚠️ Composed server-side rather than left to the reader, for 555 #: ``expects_report``'s reason: two clients deriving "is it still 556 #: live" from ``revoked_at`` and ``expires_at`` eventually disagree 557 #: with the resolver, and the direction that disagrees silently is 558 #: the one that says a leaked credential is dead. 559 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.
515class TokenTraceRequest(CtfyModel): 516 """Body for ``POST /admin/token-trace``. 517 518 ⚠️ **The secret travels in the body, never a query parameter** — 519 ``AnswerTraceRequest``'s reason, on a value that is strictly worse 520 to leak: a live bearer in a URL lands in every access log, proxy 521 cache and browser history between the operator and the app, which 522 is the exact accident this route exists to clean up after. 523 """ 524 525 token: str = Field(min_length=1, max_length=512)
Body for POST /admin/token-trace.
⚠️ The secret travels in the body, never a query parameter —
AnswerTraceRequest's reason, on a value that is strictly worse
to leak: a live bearer in a URL lands in every access log, proxy
cache and browser history between the operator and the app, which
is the exact accident this route exists to clean up after.
562class TokenTraceResponse(CtfyModel): 563 """Result of ``POST /admin/token-trace``. 564 565 ⚠️ **A miss is ``found: false`` at 200, never a 404.** "That string 566 is not a token this deployment ever minted" is the answer the 567 operator asked for — it is what clears a scare — while a 404 reads 568 as *the route is wrong* and sends them to check the URL. The 569 registry's don't-leak-existence rule does not transfer: the caller 570 is a super-admin who already holds every token row. 571 572 ``chain`` is the whole rotation lineage, oldest first, so the reply 573 to *"this secret is in a paste bin"* carries the thing the operator 574 needs next — which token is live now. A token that was never 575 rotated yields a chain of one. 576 """ 577 578 found: bool = False 579 #: The traced row's id, or ``""``. Present beside ``chain`` because 580 #: the id is what ``TokensPage`` prints and what a revoke names. 581 token_id: str = "" 582 owner_user_id: str = "" 583 owner_display_name: str = "" 584 chain: list[TokenTraceLink] = Field(default_factory=list)
Result of POST /admin/token-trace.
⚠️ A miss is found: false at 200, never a 404. "That string
is not a token this deployment ever minted" is the answer the
operator asked for — it is what clears a scare — while a 404 reads
as the route is wrong and sends them to check the URL. The
registry's don't-leak-existence rule does not transfer: the caller
is a super-admin who already holds every token row.
chain is the whole rotation lineage, oldest first, so the reply
to "this secret is in a paste bin" carries the thing the operator
needs next — which token is live now. A token that was never
rotated yields a chain of one.
442class TrafficWitness(CtfyModel): 443 """One address seen on a box, and the second it was seen in. 444 445 §7.1's correlation asks a gamebox's capture *who touched it*, and 446 this is that answer reduced to what the question needs. The whole 447 flow — both bodies, ~100 kB apiece — never crosses the wire for it. 448 449 ⚠️ ``second`` is ``""`` when the capture carried no timestamp, and 450 a reader must treat that as **every round**, never as epoch. It is 451 still evidence somebody was there; the only thing it cannot do is 452 say which round, so it may clear a capture and may not place one. 453 """ 454 455 ip: str = "" 456 second: str = ""
One address seen on a box, and the second it was seen in.
§7.1's correlation asks a gamebox's capture who touched it, and this is that answer reduced to what the question needs. The whole flow — both bodies, ~100 kB apiece — never crosses the wire for it.
⚠️ second is "" when the capture carried no timestamp, and
a reader must treat that as every round, never as epoch. It is
still evidence somebody was there; the only thing it cannot do is
say which round, so it may clear a capture and may not place one.
471class TrendPoint(CtfyModel): 472 """One UTC-day bucket on the solve-trend line.""" 473 474 date: str = "" # YYYY-MM-DD, UTC 475 solves: int = 0
One UTC-day bucket on the solve-trend line.
286class UpdateUserRoleRequest(CtfyModel): 287 """Body for ``PATCH /admin/users/{user_id}/role``. 288 289 Only ``"admin"`` and ``"user"`` are accepted — granting 290 ``"super_admin"`` is reserved for the env allowlist 291 (``CTFY_SUPER_ADMIN_EMAILS``) so the trust anchor stays out-of-band. 292 """ 293 294 role: Literal["admin", "user"]
Body for PATCH /admin/users/{user_id}/role.
Only "admin" and "user" are accepted — granting
"super_admin" is reserved for the env allowlist
(CTFY_SUPER_ADMIN_EMAILS) so the trust anchor stays out-of-band.
274class UpdateUserStandingsRequest(CtfyModel): 275 """Body for ``PATCH /admin/users/{user_id}/standings``. 276 277 ``excluded`` is the *decision*, not a timestamp: the caller says 278 whether the account counts, and the server stamps when. A body 279 carrying the moment would let two admins disagree about it and would 280 make "undo" mean writing a value rather than clearing one. 281 """ 282 283 excluded: bool
Body for PATCH /admin/users/{user_id}/standings.
excluded is the decision, not a timestamp: the caller says
whether the account counts, and the server stamps when. A body
carrying the moment would let two admins disagree about it and would
make "undo" mean writing a value rather than clearing one.
178class UserEmailInfo(CtfyModel): 179 """One address on the calling account, as `/me/emails` publishes it. 180 181 ⚠️ **``is_primary`` is a ``computed_field`` composed by the route 182 from ``users.email``, never a stored column.** There is no such 183 column: the primary *is* whichever address ``users.email`` names, so 184 a stored flag would be a second claim about the same fact, free to 185 say two addresses are primary or none is. The route knows the 186 account, so it can answer it; the table cannot be wrong about it. 187 188 ⚠️ **``verified`` mirrors ``EmailVerificationStatus`` rather than 189 inventing a second spelling** — one fact, published in the shape a 190 client reads, and never able to disagree with the timestamp beside 191 it. 192 """ 193 194 email: str = "" 195 verified_at: AwareDatetime | None = None 196 #: ``""`` = the account proved this itself; a user id = a super-admin 197 #: vouched for it. Published because "verified" means two different 198 #: things to an operator reading a roster, and the account is 199 #: entitled to know which one its own row carries. 200 verified_by: str = "" 201 added_at: AwareDatetime | None = None 202 primary: bool = False 203 204 @computed_field 205 @property 206 def verified(self) -> bool: 207 return self.verified_at is not None
One address on the calling account, as /me/emails publishes it.
⚠️ is_primary is a computed_field composed by the route
from users.email, never a stored column. There is no such
column: the primary is whichever address users.email names, so
a stored flag would be a second claim about the same fact, free to
say two addresses are primary or none is. The route knows the
account, so it can answer it; the table cannot be wrong about it.
⚠️ verified mirrors EmailVerificationStatus rather than
inventing a second spelling — one fact, published in the shape a
client reads, and never able to disagree with the timestamp beside
it.
19class UserInfo(CtfyModel): 20 """A user's public-facing profile.""" 21 22 id: str 23 display_name: str = "" 24 avatar_url: str = "" 25 created_at: datetime | None = None 26 bio: str | None = None 27 country: str | None = None 28 website_url: str | None = None 29 timezone: str | None = None 30 social_links: dict[str, str] = Field(default_factory=dict) 31 # Lifetime totals across every team the user has ever been on. 32 solves: int = 0 33 attempts: int = 0 34 last_active_at: datetime | None = None 35 # A person, somebody's agent, or the platform itself — the 36 # user-level axis, one value wider than the team-level 37 # ``ScoreboardEntry.entrant_kind`` (a service never competes, so 38 # the board type cannot say it). The profile route keeps answering 39 # 200 for non-humans (activity rows link every ``team``-actor to 40 # ``/users/{id}``, so a 404 would cut those links) and this field is 41 # what lets the page say what it is showing instead of dressing an 42 # agent — or the platform — as a person. Defaults to ``human`` so an 43 # older server never paints non-human chrome on a person. 44 entrant_kind: UserKind = "human" 45 #: The institution this account publishes, or ``None``. 46 #: 47 #: ⚠️ **``None`` is the default and means *no badge*, which is the 48 #: whole decision rather than an absent value.** Deriving one from 49 #: any proved address would retroactively publish a fact about every 50 #: account that has ever verified one — including everybody who 51 #: attaches a university address this month *because* the feature 52 #: exists. The person proved the mailbox to the platform; publishing 53 #: an inference from it is a second act, and silence here is the 54 #: honest statement that they have not taken it. 55 #: 56 #: ⚠️ **Composed, never a column.** It is resolved on every read 57 #: through ``user_emails`` and the operator's catalogue 58 #: (`ctfy.server.affiliations`), so it disappears the moment the 59 #: address is removed, stops being verified, is handed to another 60 #: account, or the operator deletes the row — with no backfill and 61 #: no cleanup pass. 62 affiliation: AffiliationInfo | None = None
A user's public-facing profile.
1373class UserScoreboardEntry(CtfyModel): 1374 """One row on the user-ranked global scoreboard. 1375 1376 Aggregates a single user's solves across every team they've 1377 ever been on (each ``SolveState`` carries both ``user_id`` and 1378 ``team_id``, so per-user totals are a straight filter over the 1379 global solve table). Excludes users with zero solves to keep 1380 the public list compact. 1381 """ 1382 1383 rank: int = 0 1384 user_id: str 1385 display_name: str 1386 avatar_url: str = "" 1387 country: str = "" 1388 # Total flags solved across all teams the user has ever been on. 1389 flags_solved: int = 0 1390 # Challenges fully solved (every declared flag captured). 1391 solved: int = 0 1392 last_solve_at: datetime | None = None 1393 #: The institution this person publishes, or ``None``. 1394 #: 1395 #: ⚠️ **On the *people* board and deliberately not on 1396 #: :class:`ScoreboardEntry`.** A team is not an institution: a squad 1397 #: of five holds five affiliations and picking a majority invents a 1398 #: claim nobody made — the ``Standing.player_id`` trap, an id 1399 #: standing in for the wrong identity. A team's own affiliation is a 1400 #: captain-set field backed by a member's proved address, which is a 1401 #: different feature. 1402 #: 1403 #: ⚠️ **And that absence is what keeps anonymity out of this.** Every 1404 #: applier in `ctfy.server.anonymity` publishes an *alias*, and a 1405 #: badge beside an alias is not pseudonymity — "the Tsinghua entrant" 1406 #: narrows a field of two hundred to a handful. This board aliases 1407 #: nobody (it is a career aggregate under real names, and 1408 #: `WithheldSittings` drops a withheld sitting's captures from the 1409 #: totals instead), so the badge here discloses nothing the row's own 1410 #: ``display_name`` does not. ``test_the_badge_never_reaches_an_alias`` 1411 #: is what stops the field arriving on a model that *is* aliased. 1412 affiliation: AffiliationInfo | None = None
One row on the user-ranked global scoreboard.
Aggregates a single user's solves across every team they've
ever been on (each SolveState carries both user_id and
team_id, so per-user totals are a straight filter over the
global solve table). Excludes users with zero solves to keep
the public list compact.
491class UserSolveTrend(CtfyModel): 492 """The calling user's daily solve count for the last ``days`` UTC days, 493 aggregated across every team they have ever been on (matched by 494 ``user_id`` on each :class:`SolveState`). 495 496 Powers the Dashboard's "personal solve trend" chart, which is 497 user-scoped — independent of which competition the user is currently 498 registered for. Multi-flag challenges count once per flag day, the 499 same way :class:`ProfileStats.solve_trend` does it. 500 """ 501 502 solve_trend: list[TrendPoint] = Field(default_factory=list)
The calling user's daily solve count for the last days UTC days,
aggregated across every team they have ever been on (matched by
user_id on each SolveState).
Powers the Dashboard's "personal solve trend" chart, which is
user-scoped — independent of which competition the user is currently
registered for. Multi-flag challenges count once per flag day, the
same way ProfileStats.solve_trend does it.
204class VendorStandardSet(CtfyModel): 205 """A vendor-facing summary of one operator eval standard set. 206 207 A standard set is a competition marked ``eval_standard`` — the fixed 208 challenge subset a vendor self-service campaign runs a model over. This 209 projection is what a ``pv_`` token can see (id / title / size / corpus 210 pin), enough to populate the campaign-launch picker without exposing the 211 full competition surface or requiring a platform user token. 212 """ 213 214 id: str = "" 215 title: str = "" 216 description: str = "" # Markdown 217 challenge_count: int = 0 218 #: corpus snapshot the set was certified against (reproducibility pin). 219 corpus_sha: str = ""
A vendor-facing summary of one operator eval standard set.
A standard set is a competition marked eval_standard — the fixed
challenge subset a vendor self-service campaign runs a model over. This
projection is what a pv_ token can see (id / title / size / corpus
pin), enough to populate the campaign-launch picker without exposing the
full competition surface or requiring a platform user token.
115class VendorStanding(CtfyModel): 116 """One vendor's aggregate scorecard across all its evaluated models. 117 118 The vendor's models are pooled: a challenge counts as *solved* if **any** 119 of the vendor's models solved it (the vendor's collective capability), so 120 a vendor fielding several models isn't double-counted per challenge. 121 """ 122 123 rank: int = 0 124 vendor: str = "" 125 #: the vendor's curated display name, falling back to the slug. Never 126 #: blank here — an unattributed model contributes to no vendor at all, 127 #: so every row in this table has a slug to fall back to. 128 display_name: str = "" 129 models: int = 0 130 challenges_attempted: int = 0 131 challenges_solved: int = 0 132 solve_rate: float = 0.0 133 questions_solved: int = 0 134 total_tokens: int = 0 135 #: estimated USD cost summed across the vendor's models (0 when unpriced). 136 cost_usd: float = 0.0
One vendor's aggregate scorecard across all its evaluated models.
The vendor's models are pooled: a challenge counts as solved if any of the vendor's models solved it (the vendor's collective capability), so a vendor fielding several models isn't double-counted per challenge.
225class VerifyAddedEmailRequest(AddEmailRequest): 226 """Body for ``POST /me/emails/verify``. 227 228 Carries the address as well as the code because a caller may have 229 more than one add in flight, and because the code table is keyed by 230 ``(address, purpose)`` — asking which address this code is for is 231 the lookup, not a convenience. 232 """ 233 234 code: str = Field(min_length=1, max_length=16) 235 #: Go ahead even though the address is the other account's last 236 #: credential. 237 #: 238 #: ⚠️ **Required for that one case, and it defaults to refusing.** 239 #: A caller that never heard of handovers — an older client, the 240 #: CLI, an agent — must not be able to make an account unreachable 241 #: by omission: the empty case has to assert *no*, or the default is 242 #: a new claim about a row nobody can sign into afterwards. Every 243 #: other handover needs nothing, because the losing account keeps a 244 #: way in and is told what happened. 245 acknowledge_orphan: bool = False
Body for POST /me/emails/verify.
Carries the address as well as the code because a caller may have
more than one add in flight, and because the code table is keyed by
(address, purpose) — asking which address this code is for is
the lookup, not a convenience.
576class VerifyAnswerRequest(CtfyModel): 577 """Oracle-mode answer check — same grader as /submissions but writes 578 no submission record. Carries the same shape as :class:`SubmissionCreate` 579 minus the audit trail. 580 """ 581 582 # Which question on the challenge the agent is checking. Defaults 583 # to ``"flag"`` for single-question challenges. 584 question_id: str = "flag" 585 # The answer to verify (str for dynamic / static / single_select; 586 # list[str] for multi_select). 587 answer: str | list[str]
Oracle-mode answer check — same grader as /submissions but writes
no submission record. Carries the same shape as SubmissionCreate
minus the audit trail.
590class VerifyAnswerResponse(CtfyModel): 591 correct: bool 592 # Echoes the request's ``question_id`` on a correct match; ``None`` 593 # on a wrong claim. Useful for batched verification calls where the 594 # client wants to confirm which check the response refers to. 595 question_id: str | None = None
Project-wide Pydantic base.
The one config tweak: json_schema_serialization_defaults_required=True.
By default Pydantic treats "has a default" as "optional in the JSON
Schema", which makes every frontend type generated from our OpenAPI
doc become field?: T. But at serialization time these fields are
always present (the default fills in), so the client never legitimately
sees undefined. Marking them required in the schema gives the
frontend accurate non-optional types without forcing the backend to
drop sensible defaults.
126class VerifyEmailRequest(CtfyModel): 127 """Body for ``POST /auth/email-verification/verify``. 128 129 ⚠️ **No address field, deliberately.** The code always goes to — and 130 is checked against — the address already on the calling account. An 131 address in the body would make this endpoint a way to mail six 132 digits to anywhere, signed by the platform, which is the spam cannon 133 the sign-in flow's rate limits exist to prevent; it would also let a 134 caller verify a *different* address onto their own account. 135 """ 136 137 code: str = Field(min_length=1, max_length=16)
Body for POST /auth/email-verification/verify.
⚠️ No address field, deliberately. The code always goes to — and is checked against — the address already on the calling account. An address in the body would make this endpoint a way to mail six digits to anywhere, signed by the platform, which is the spam cannon the sign-in flow's rate limits exist to prevent; it would also let a caller verify a different address onto their own account.
1578class VirtualGhostBoard(CtfyModel): 1579 """The source contest's standings at the instant this replay has 1580 reached, with the replayer merged in and ranked among them. 1581 1582 ⚠️ Nothing here is stored. The board is 1583 `compute_scoreboard_filtered` with an `until_iso`, which the 1584 platform has always supported — the replay's own progress and the 1585 source's progression are two reads of facts that already exist. 1586 """ 1587 1588 source_competition_id: str 1589 #: How far into the replay the reader is. The client renders a clock 1590 #: from this rather than recomputing it from `starts_at`, so the two 1591 #: cannot disagree about which minute the ghosts are showing. 1592 elapsed_seconds: int = 0 1593 #: The instant in the *source* being shown. Bounded by its end. 1594 cutoff: datetime | None = None 1595 items: list[VirtualGhostRow] = Field(default_factory=list)
The source contest's standings at the instant this replay has reached, with the replayer merged in and ranked among them.
⚠️ Nothing here is stored. The board is
compute_scoreboard_filtered with an until_iso, which the
platform has always supported — the replay's own progress and the
source's progression are two reads of facts that already exist.
1567class VirtualGhostRow(ScoreboardEntry): 1568 """A standings row on a replay's ghost board. 1569 1570 `is_me` is the one thing a client cannot derive: the replayer's row 1571 comes from a *different competition* than the ghosts', so there is no 1572 team id it could compare against. 1573 """ 1574 1575 is_me: bool = False
A standings row on a replay's ghost board.
is_me is the one thing a client cannot derive: the replayer's row
comes from a different competition than the ghosts', so there is no
team id it could compare against.