diff --git a/src/apps/opportunities/README.md b/src/apps/opportunities/README.md
index 60695776b..36c8e3f2f 100644
--- a/src/apps/opportunities/README.md
+++ b/src/apps/opportunities/README.md
@@ -484,6 +484,14 @@ views refresh attempts and their embedded scorer summaries so completed results
appear without a page reload. Their actions include the clean submission, scorer
artifacts, and submission history, while the single page-level button owns the
Review App handoff.
+Artifact controls in All Submissions, My Submissions, and submission history
+use the challenge's exact `COMPLETED` status for Marathon Matches. Before completion
+(including cancelled challenges), contestants can list and download only their own
+regular artifacts. After completion, registered contestants can download regular
+and internal provisional/system artifacts for their own and other members' attempts.
+Scores and phase end dates never unlock artifacts. Existing admin/copilot access
+is retained. The shared dialog filters internal entries when permission changes,
+and Review API independently authorizes both listing and every download.
The Marathon Match My Submissions table reserves enough width for the complete
submission timestamp and keeps its date heading and sort icon on one line,
aligned with the dates beneath it. Score columns remain right aligned.
@@ -495,6 +503,8 @@ compact close action only at that breakpoint. History requests include the
selected member ID;
Review API returns every attempt to that member and authorized challenge staff,
while ordinary viewers receive only the selected entrant's latest attempt.
+Registered contestants of a completed Marathon Match can also inspect every
+historical attempt to download its released scorer artifacts.
Design submissions can be deleted only while Submission or Checkpoint
Submission is open. Successful deletion updates both the challenge and member
submission counts as well as the current list. Replacing a Design submission
diff --git a/src/apps/opportunities/src/components/SubmissionArtifactsModal.spec.tsx b/src/apps/opportunities/src/components/SubmissionArtifactsModal.spec.tsx
index 5e1036595..812f5f55f 100644
--- a/src/apps/opportunities/src/components/SubmissionArtifactsModal.spec.tsx
+++ b/src/apps/opportunities/src/components/SubmissionArtifactsModal.spec.tsx
@@ -65,6 +65,24 @@ describe('SubmissionArtifactsModal', () => {
.mockImplementation(() => undefined)
})
+ it.each([false, true])('filters internal artifact controls when access is %s', async allowInternalArtifacts => {
+ mockedGetArtifacts.mockResolvedValue(['regular-results', 'provisional-internal', 'system-INTERNAL'])
+ render(
+ new Map() }}>
+
+ ,
+ )
+ expect(await screen.findByRole('button', { name: 'Download artifact regular-results' }))
+ .toBeInTheDocument()
+ expect(screen.queryAllByRole('button', { name: /Download artifact .*internal/i }))
+ .toHaveLength(allowInternalArtifacts ? 2 : 0)
+ })
+
it('loads and downloads the selected scorer artifact through Review API', async () => {
render(
new Map() }}>
diff --git a/src/apps/opportunities/src/components/SubmissionArtifactsModal.tsx b/src/apps/opportunities/src/components/SubmissionArtifactsModal.tsx
index 731b15421..6a3fa0ed6 100644
--- a/src/apps/opportunities/src/components/SubmissionArtifactsModal.tsx
+++ b/src/apps/opportunities/src/components/SubmissionArtifactsModal.tsx
@@ -13,6 +13,7 @@ import {
import styles from './SubmissionArtifactsModal.module.scss'
interface SubmissionArtifactsModalProps {
+ allowInternalArtifacts?: boolean
onClose: () => void
open: boolean
submissionId?: string
@@ -70,6 +71,7 @@ function saveArtifact(blob: Blob, filename: string): void {
/**
* Lists and downloads scorer-generated files for one authored submission.
*
+ * Internal files are displayed only with explicit completion/management permission.
* @param props selected submission, visibility, and close callback.
* @returns artifact dialog with loading, error, empty, and download states.
* @throws Does not throw; request failures remain visible in the dialog.
@@ -78,7 +80,7 @@ export const SubmissionArtifactsModal: FC = props
const [downloadingArtifactId, setDownloadingArtifactId] = useState()
const response: SWRResponse = useSWR(
props.open && props.submissionId
- ? ['opportunities:submission-artifacts', props.submissionId]
+ ? ['opportunities:submission-artifacts', props.submissionId, !!props.allowInternalArtifacts]
: undefined,
() => getChallengeSubmissionArtifacts(props.submissionId as string),
{ revalidateOnFocus: false, shouldRetryOnError: false },
@@ -106,6 +108,12 @@ export const SubmissionArtifactsModal: FC = props
}
}
+ // Also filter cached responses when completion or viewer permissions change.
+ const artifacts = (response.data ?? []).filter(artifactId => (
+ props.allowInternalArtifacts || !artifactId.toLowerCase()
+ .includes('internal')
+ ))
+
let content
if (response.isValidating && !response.data) {
content =
@@ -116,7 +124,7 @@ export const SubmissionArtifactsModal: FC = props
)
- } else if (!response.data?.length) {
+ } else if (!artifacts.length) {
content =