diff --git a/pygitweb/dependencies.py b/pygitweb/dependencies.py
new file mode 100644
index 0000000..6c0ec73
--- /dev/null
+++ b/pygitweb/dependencies.py
@@ -0,0 +1,87 @@
+"""
+FastAPI dependencies for shared request validation (exported repositories, etc.).
+"""
+
+from __future__ import annotations
+
+from typing import Annotated, Any
+
+from fastapi import Depends, Form, HTTPException, Query
+
+from pygitweb.config import settings
+from pygitweb.projects import git_get_projects_list, project_in_list
+from pygitweb.validation import is_valid_project
+
+
+def _visible_projects_list() -> list[dict[str, Any]]:
+	return git_get_projects_list(
+		filter_path="",
+		paranoid=settings.STRICT_EXPORT,
+		export_ok=settings.EXPORT_OK,
+	)
+
+
+def project_visible_in_list(project: str) -> bool:
+	"""True if ``project`` appears in the same listing as the project picker."""
+	return project_in_list(project, _visible_projects_list)
+
+
+def require_valid_project(project: str) -> str:
+	"""Reject empty or unknown project paths (export_ok, strict list, etc.)."""
+	if not project or project == "":
+		raise HTTPException(status_code=400, detail="Project needed")
+	if not is_valid_project(
+		project,
+		settings.PROJECTROOT,
+		settings.EXPORT_OK,
+		settings.STRICT_EXPORT,
+		project_visible_in_list,
+	):
+		raise HTTPException(status_code=404, detail="No such project")
+	return project
+
+
+def require_valid_project_name(name: str) -> str:
+	"""Same as ``require_valid_project`` for a path parameter named ``name``."""
+	return require_valid_project(name)
+
+
+def require_valid_project_from_query(
+	project: Annotated[str, Query(..., description="Project path")],
+) -> str:
+	return require_valid_project(project)
+
+
+def require_valid_project_form(project: Annotated[str, Form()]) -> str:
+	return require_valid_project(project)
+
+
+def require_notify_project(
+	project: Annotated[str | None, Query(alias="project", description="Repository path")] = None,
+) -> str:
+	"""Internal notify: project must appear in the visible list (hook / long-poll contract)."""
+	if not project or not project.strip():
+		raise HTTPException(status_code=400, detail="project query parameter required")
+	p = project.strip()
+	if not project_visible_in_list(p):
+		raise HTTPException(status_code=404, detail="No such project")
+	return p
+
+
+def board_create_resolved_project(
+	p: Annotated[str | None, Query(alias="p")] = None,
+	project: Annotated[str | None, Query(alias="project")] = None,
+) -> str:
+	"""Resolve ``?p=`` / ``?project=`` then validate as a normal project path."""
+	chosen = (project or p or "").strip()
+	if not chosen:
+		raise HTTPException(status_code=400, detail="Project needed (use ?project= or ?p=)")
+	return require_valid_project(chosen)
+
+
+ValidatedPathProject = Annotated[str, Depends(require_valid_project)]
+ValidatedQueryProject = Annotated[str, Depends(require_valid_project_from_query)]
+ValidatedSettingsProject = Annotated[str, Depends(require_valid_project_name)]
+ValidatedBoardCreateProject = Annotated[str, Depends(board_create_resolved_project)]
+ValidatedNotifyProject = Annotated[str, Depends(require_notify_project)]
+ValidatedFormProject = Annotated[str, Depends(require_valid_project_form)]
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 32f7488..a7ab708 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -58,6 +58,12 @@ from pygitweb.auth import (
 )
 from pygitweb.change_queue import CHANGE_QUEUE
 from pygitweb.config import ACTIONS, get_loadavg, settings
+from pygitweb.dependencies import (
+	ValidatedBoardCreateProject,
+	ValidatedNotifyProject,
+	ValidatedPathProject,
+	project_visible_in_list,
+)
 from pygitweb.formatting import age_string
 from pygitweb.git_helpers import git_get_references, git_get_type
 from pygitweb.hooks_install import (
@@ -96,7 +102,7 @@ from pygitweb.tasks import (
 )
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
 from pygitweb.timeline_cache import clear_timeline_cache_async, get_all_timeline_events, warm_timeline_cache_async
-from pygitweb.validation import is_valid_pathname, is_valid_project
+from pygitweb.validation import is_valid_pathname
 
 UPDATES_SUPPORTED_ACTIONS: frozenset[str] = frozenset({"history", "log", "shortlog", "heads", "tags"})
 
@@ -198,15 +204,6 @@ if _static_dir.is_dir():
 	app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
 
 
-def _project_in_list(project: str) -> bool:
-	lst = git_get_projects_list(
-		filter_path="",
-		paranoid=settings.STRICT_EXPORT,
-		export_ok=settings.EXPORT_OK,
-	)
-	return any(p.get("path") == project for p in lst)
-
-
 app.include_router(auth_router)
 app.include_router(settings_router)
 app.include_router(board_router, prefix="/board")
@@ -228,20 +225,6 @@ async def loadavg_middleware(request: Request, call_next):
 	return await call_next(request)
 
 
-def _validate_project(project: str | None) -> str:
-	if not project:
-		raise HTTPException(status_code=400, detail="Project needed")
-	if not is_valid_project(
-		project,
-		settings.PROJECTROOT,
-		settings.EXPORT_OK,
-		settings.STRICT_EXPORT,
-		_project_in_list,
-	):
-		raise HTTPException(status_code=404, detail="No such project")
-	return project
-
-
 def _project_subpage_rows(project: str) -> list[list[str]]:
 	project_enc = quote(project, safe="/")
 	rows: list[list[str]] = []
@@ -421,22 +404,17 @@ def git_opml():
 @app.get("/board/create", response_class=RedirectResponse)
 def board_create_page(
 	_: Annotated[None, Depends(require_active_user_if_auth_enabled)],
-	request: Request,
-	project: Annotated[str | None, Query(alias="p")] = None,
+	project: ValidatedBoardCreateProject,
 ) -> RedirectResponse:
 	"""Create a board named 'Tasks' (refs/tags/boards/Tasks) and redirect to the project summary."""
-	p = project or request.query_params.get("project")
-	if not p:
-		raise HTTPException(status_code=400, detail="Project needed (use ?project= or ?p=)")
-	_validate_project(p)
 	try:
-		create_board_for_project(p, name="Tasks", description="")
+		create_board_for_project(project, name="Tasks", description="")
 	except HTTPException as e:
 		if e.status_code == 409:
 			pass
 		else:
 			raise
-	p_url = quote(p, safe="/")
+	p_url = quote(project, safe="/")
 	return RedirectResponse(url=f"/project/{p_url}", status_code=303)
 
 
@@ -446,16 +424,13 @@ _OPTIONAL_REPO_ZIP = File(default=None)
 
 
 @app.get("/projectnamevalid", response_class=HTMLResponse)
-def addproject_namevalid(request: Request):
+def addproject_namevalid(name: Annotated[str, Query()]):
 	"""Check if project name is valid."""
-	project_name = request.query_params.get("name")
-	if not project_name:
-		raise HTTPException(status_code=400, detail="Param 'name' required")
-	if not is_valid_pathname(project_name):
+	if not is_valid_pathname(name):
 		raise HTTPException(status_code=400, detail="Invalid project name (no path segments)")
-	if _project_in_list(project_name):
+	if project_visible_in_list(name):
 		raise HTTPException(status_code=409, detail="Project already exists")
-	return HTMLResponse(status_code=200, content=f"Project name '{project_name}' is valid")
+	return HTMLResponse(status_code=200, content=f"Project name '{name}' is valid")
 
 
 @app.get("/addproject", response_class=HTMLResponse)
@@ -493,7 +468,7 @@ async def addproject_submit(
 		raise HTTPException(status_code=400, detail="Project name is required")
 	if not is_valid_pathname(project_name):
 		raise HTTPException(status_code=400, detail="Invalid project name")
-	if _project_in_list(project_name):
+	if project_visible_in_list(project_name):
 		raise HTTPException(status_code=409, detail="Project already exists")
 
 	remote_url = (pull_from_remote or "").strip()
@@ -537,11 +512,10 @@ async def addproject_submit(
 
 @app.get("/project/{project:path}/board/", response_class=HTMLResponse)
 def project_board(
-	project: str,
+	project: ValidatedPathProject,
 	board: Annotated[str, Query(alias="b")] = "Tasks",
 ) -> HTMLResponse:
 	"""Board view: columns (dropzones) and task cards."""
-	_validate_project(project)
 	board_name = board
 	project_url = f"/project/{quote(project, safe='/')}"
 	board_url = f"{project_url}/board/"
@@ -564,34 +538,30 @@ def project_board(
 
 
 @app.get("/project/{project:path}/summary-refs")
-def project_summary_refs(project: str) -> dict[str, list[dict[str, str]]]:
-	_validate_project(project)
+def project_summary_refs(project: ValidatedPathProject) -> dict[str, list[dict[str, str]]]:
 	return {"options": summary_ref_options(project)}
 
 
 @app.get("/project/{project:path}/summary-ref-state")
 def project_summary_ref_state(
-	project: str,
+	project: ValidatedPathProject,
 	ref: Annotated[str | None, Query(alias="ref")] = None,
 ) -> dict[str, str]:
-	_validate_project(project)
 	return summary_ref_state(project, ref)
 
 
 @app.get("/project/{project:path}/search", response_class=HTMLResponse)
-def project_search_page(project: str) -> HTMLResponse:
+def project_search_page(project: ValidatedPathProject) -> HTMLResponse:
 	"""Per-project search page (UI only; results fetched from /project/{project}?a=search)."""
-	_validate_project(project)
 	return git_search_page(project)
 
 
 @app.get("/project/{project:path}/subpage/{subpage_name}", response_class=HTMLResponse)
 def project_subpage(
-	project: str,
+	project: ValidatedPathProject,
 	subpage_name: str,
 	raw: Annotated[bool, Query()] = False,
 ) -> HTMLResponse:
-	_validate_project(project)
 	for subpage in SUBPAGES:
 		if subpage.subpage_name() != subpage_name:
 			continue
@@ -624,7 +594,7 @@ def project_subpage(
 
 @app.post("/project/{project:path}/hook", response_class=JSONResponse)
 def project_hook(
-	project: str,
+	project: ValidatedPathProject,
 	name: Annotated[str, Query(alias="name")],
 	token: Annotated[str | None, Depends(access_token_from_request)],
 	op: Annotated[str, Query(alias="op")] = "check",
@@ -635,7 +605,6 @@ def project_hook(
 	(e.g. `update`, which installs both `post-commit.notify` and `post-receive.notify`).
 	`op` is `add`, `remove`, or `check`.
 	"""
-	_validate_project(project)
 	if op != "check":
 		ensure_active_user_if_auth_enabled(token)
 	if op not in ("add", "remove", "check"):
@@ -709,9 +678,8 @@ def project_hook(
 
 
 @app.get("/project/{project:path}/hooks", response_class=JSONResponse)
-def project_hooks_list(project: str) -> JSONResponse:
+def project_hooks_list(project: ValidatedPathProject) -> JSONResponse:
 	"""List all hook samples and bundles with current status in this project's hooks dir."""
-	_validate_project(project)
 	samples_out: list[dict[str, object]] = []
 	for sample in list_samples():
 		try:
@@ -742,17 +710,11 @@ def project_hooks_list(project: str) -> JSONResponse:
 
 
 @app.post("/_internal/notify", response_class=JSONResponse)
-async def internal_notify(
-	project: Annotated[str | None, Query(alias="project")] = None,
-) -> JSONResponse:
+async def internal_notify(project: ValidatedNotifyProject) -> JSONResponse:
 	"""Notify long-polling subscribers that a project's refs changed.
 
 	Intended for server-side hooks (pre-receive / post-receive) running on the same host.
 	"""
-	if not project:
-		raise HTTPException(status_code=400, detail="project query parameter required")
-	if not _project_in_list(project):
-		raise HTTPException(status_code=404, detail="No such project")
 	woken = await CHANGE_QUEUE.notify(project)
 	return JSONResponse({"project": project, "waiters_woken": woken})
 
@@ -760,7 +722,7 @@ async def internal_notify(
 @app.get("/project/{project:path}", response_class=HTMLResponse)
 async def dispatch(
 	request: Request,
-	project: str,
+	project: ValidatedPathProject,
 	a: Annotated[str | None, Query(alias="a")] = None,
 	h: Annotated[str | None, Query(alias="h")] = None,
 	hb: Annotated[str | None, Query(alias="hb")] = None,
@@ -775,9 +737,6 @@ async def dispatch(
 	Dispatch by path: /project/{project} -> summary; /project/{project}/action/... -> action.
 	Port of dispatch + run_request path handling.
 	"""
-	if not project or project == "":
-		raise HTTPException(status_code=400, detail="Project needed")
-	_validate_project(project)
 	action = a
 	hash_param = h or hb
 	file_name = f
diff --git a/pygitweb/merge_requests.py b/pygitweb/merge_requests.py
index 07770ab..8c0d761 100644
--- a/pygitweb/merge_requests.py
+++ b/pygitweb/merge_requests.py
@@ -13,8 +13,8 @@ from fastapi.responses import HTMLResponse, RedirectResponse, Response
 from pygittools.merge import MergeRequest, MergeRequestStatus, get_merge_request_by_oid
 from pygitweb.auth import require_active_user_if_auth_enabled
 from pygitweb.config import settings
+from pygitweb.dependencies import ValidatedFormProject, ValidatedQueryProject
 from pygitweb.git_helpers import open_repo
-from pygitweb.tasks import _validate_project
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
 from pygitweb.validation import is_valid_ref_format
 
@@ -158,12 +158,11 @@ def merge_request_tag_response(
 
 @merge_router.post("/create")
 def merge_request_create(
-	project: str = Form(...),
+	project: ValidatedFormProject,
 	theirs: str = Form(...),
 	ours: str = Form(...),
 	title: str = Form(""),
 ) -> RedirectResponse:
-	_validate_project(project)
 	theirs_ref = theirs.strip()
 	ours_ref = ours.strip()
 	if not theirs_ref or not ours_ref:
@@ -198,10 +197,9 @@ def merge_request_create(
 
 @merge_router.post("/ff")
 def merge_fast_forward(
-	project: str = Query(..., description="Project path"),
+	project: ValidatedQueryProject,
 	h: str = Query(..., description="Annotated tag object id (hex)"),
 ) -> RedirectResponse:
-	_validate_project(project)
 	if not h or not is_valid_ref_format(h):
 		raise HTTPException(status_code=400, detail="Invalid tag hash (h)")
 	repo = open_repo(project)
diff --git a/pygitweb/settings.py b/pygitweb/settings.py
index e5b8f50..4978964 100644
--- a/pygitweb/settings.py
+++ b/pygitweb/settings.py
@@ -11,13 +11,12 @@ from typing import Any
 from urllib.parse import quote
 
 import pygit2
-from fastapi import APIRouter, HTTPException, Request
+from fastapi import APIRouter, Request
 from fastapi.responses import HTMLResponse, RedirectResponse
 
 from pygitweb.config import settings
-from pygitweb.projects import git_get_projects_list
+from pygitweb.dependencies import ValidatedSettingsProject
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
-from pygitweb.validation import is_valid_project
 
 
 def _quote_path(path: str) -> str:
@@ -356,48 +355,22 @@ async def settings_pygit2_submit(request: Request):
 
 
 # ---------- Routes: Project ----------
-def _project_in_list(project: str) -> bool:
-	"""True if project is in the visible project list."""
-	lst = git_get_projects_list(
-		filter_path="",
-		paranoid=settings.STRICT_EXPORT,
-		export_ok=settings.EXPORT_OK,
-	)
-	return any(p.get("path") == project for p in lst)
-
-
-def _validate_project(project: str | None) -> str:
-	"""Validate project name and that it exists; raise HTTPException if not."""
-	if not project:
-		raise HTTPException(status_code=400, detail="Project needed")
-	if not is_valid_project(
-		project,
-		settings.PROJECTROOT,
-		settings.EXPORT_OK,
-		settings.STRICT_EXPORT,
-		_project_in_list,
-	):
-		raise HTTPException(status_code=404, detail="No such project")
-	return project
-
-
 @router.get("/project/{name:path}", response_class=HTMLResponse)
-def settings_project_page(request: Request, name: str):
+def settings_project_page(request: Request, name: ValidatedSettingsProject):
 	"""Project-specific git config form (local/worktree only)."""
-	project = _validate_project(name)
-	entries = _get_project_config_entries(project)
+	entries = _get_project_config_entries(name)
 	rows = [_project_config_table_row(k, v, i) for i, (k, v) in enumerate(entries)]
 	# One empty row for adding new
 	rows.append(_project_config_table_row("", "", len(entries)))
 	table_html = env.get_template("table.html").render(cols=["Key", "Value"], rows=rows)
 	pre = PREAMBLE.render(
-		title=f"{settings.SITE_NAME} - Project settings: {project}",
+		title=f"{settings.SITE_NAME} - Project settings: {name}",
 		site_name=settings.SITE_NAME,
 	)
-	submit_url = f"/settings/project/{_quote_path(project)}/submit"
-	cancel_url = f"/project/{_quote_path(project)}"
+	submit_url = f"/settings/project/{_quote_path(name)}/submit"
+	cancel_url = f"/project/{_quote_path(name)}"
 	body = env.get_template("project_settings.html").render(
-		project_title=project,
+		project_title=name,
 		submit_url=submit_url,
 		cancel_url=cancel_url,
 		table_html=table_html,
@@ -406,9 +379,8 @@ def settings_project_page(request: Request, name: str):
 
 
 @router.post("/project/{name:path}/submit", response_class=HTMLResponse)
-async def settings_project_submit(request: Request, name: str):
+async def settings_project_submit(request: Request, name: ValidatedSettingsProject):
 	"""Apply project config from form."""
-	project = _validate_project(name)
 	form = await request.form()
 	# Collect key/value pairs; keys are config_key_0, config_value_0, ...
 	indices = set()
@@ -422,11 +394,11 @@ async def settings_project_submit(request: Request, name: str):
 		v = str(form.get(f"config_value_{i}") or "").strip() or ""
 		if k:
 			pairs.append((k, v))
-	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
+	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, name))
 	cfg = repo.config
 	for config_key, config_value in pairs:
 		try:
 			cfg[config_key] = config_value
 		except (pygit2.GitError, ValueError, KeyError):
 			continue
-	return RedirectResponse(url=f"/settings/project/{_quote_path(project)}", status_code=303)
+	return RedirectResponse(url=f"/settings/project/{_quote_path(name)}", status_code=303)
diff --git a/pygitweb/tasks.py b/pygitweb/tasks.py
index cd1e61c..dd5d946 100644
--- a/pygitweb/tasks.py
+++ b/pygitweb/tasks.py
@@ -26,36 +26,12 @@ from pygittools.tasks import (
 )
 from pygitweb.auth import require_active_user_if_auth_enabled
 from pygitweb.config import settings
-from pygitweb.projects import git_get_projects_list
-from pygitweb.validation import is_valid_project
+from pygitweb.dependencies import ValidatedQueryProject
 
 # Well-known empty tree OID for boards/tasks when repo has no HEAD
 EMPTY_TREE_OID = pygit2.Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")
 
 
-def _project_in_list(project: str) -> bool:
-	lst = git_get_projects_list(
-		filter_path="",
-		paranoid=settings.STRICT_EXPORT,
-		export_ok=settings.EXPORT_OK,
-	)
-	return any(p.get("path") == project for p in lst)
-
-
-def _validate_project(project: str | None) -> str:
-	if not project:
-		raise HTTPException(status_code=400, detail="Project needed")
-	if not is_valid_project(
-		project,
-		settings.PROJECTROOT,
-		settings.EXPORT_OK,
-		settings.STRICT_EXPORT,
-		_project_in_list,
-	):
-		raise HTTPException(status_code=404, detail="No such project")
-	return project
-
-
 def _board_ref(name: str) -> str:
 	return f"{BOARD_REF_PREFIX}{name}"
 
@@ -92,12 +68,8 @@ board_router = APIRouter(tags=["boards"], dependencies=[Depends(require_active_u
 
 
 @board_router.get("/list", response_class=JSONResponse)
-def boards_list(
-	request: Request,
-	project: str = Query(..., description="Project path"),
-) -> JSONResponse:
+def boards_list(project: ValidatedQueryProject) -> JSONResponse:
 	"""List all boards for the project."""
-	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	refs = [
 		(ref_name, str(repo.references[ref_name].resolve().target))
@@ -121,13 +93,11 @@ def boards_list(
 
 @board_router.post("/create", response_class=JSONResponse)
 def boards_create(
-	request: Request,
-	project: str = Query(..., description="Project path"),
+	project: ValidatedQueryProject,
 	name: str = Query(..., description="Board name"),
 	description: str = Query("", description="Board description"),
 ) -> JSONResponse:
 	"""Create a new board."""
-	_validate_project(project)
 	if not name or "/" in name or ".." in name:
 		raise HTTPException(status_code=400, detail="Invalid board name")
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
@@ -142,12 +112,10 @@ def boards_create(
 
 @board_router.post("/delete", response_class=JSONResponse)
 def boards_delete(
-	request: Request,
-	project: str = Query(..., description="Project path"),
+	project: ValidatedQueryProject,
 	name: str = Query(..., description="Board name"),
 ) -> JSONResponse:
 	"""Delete a board (removes ref; tag object remains in ODB)."""
-	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	ref = _board_ref(name)
 	if ref not in repo.references:
@@ -218,12 +186,10 @@ def get_board_tasks_grouped(
 
 @task_router.get("/list", response_class=JSONResponse)
 def task_list(
-	request: Request,
-	project: str = Query(..., description="Project path"),
+	project: ValidatedQueryProject,
 	board: str = Query(..., description="Board name"),
 ) -> JSONResponse:
 	"""List all tasks on a board."""
-	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	ref = _board_ref(board)
 	b = get_board(repo, ref)
@@ -244,8 +210,7 @@ def task_list(
 
 @task_router.post("/create", response_class=JSONResponse)
 def task_create(
-	request: Request,
-	project: str = Query(..., description="Project path"),
+	project: ValidatedQueryProject,
 	board: str = Query(..., description="Board name"),
 	title: str = Query(..., description="Task title"),
 	description: str = Query("", description="Task description"),
@@ -255,7 +220,6 @@ def task_create(
 	due_date: str = Query("", description="Due date ISO"),
 ) -> JSONResponse:
 	"""Create a new task on a board."""
-	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	board_ref = _board_ref(board)
 	b = get_board(repo, board_ref)
@@ -296,12 +260,11 @@ def task_create(
 @task_router.post("/update", response_class=JSONResponse)
 async def task_update(
 	request: Request,
-	project: str = Query(..., description="Project path"),
+	project: ValidatedQueryProject,
 	board: str = Query(..., description="Board name"),
 	task_ref: str = Query(..., alias="task", description="Task ref (e.g. refs/tags/tasks/task_123)"),
 ) -> JSONResponse:
 	"""Update a task (body: optional title, description, status, priority, assignee, due_date)."""
-	_validate_project(project)
 	try:
 		body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
 	except Exception:
@@ -349,13 +312,11 @@ async def task_update(
 
 @task_router.post("/delete", response_class=JSONResponse)
 def task_delete(
-	request: Request,
-	project: str = Query(..., description="Project path"),
+	project: ValidatedQueryProject,
 	board: str = Query(..., description="Board name"),
 	task_ref: str = Query(..., alias="task", description="Task ref"),
 ) -> JSONResponse:
 	"""Delete a task (remove ref and remove from board.tasks)."""
-	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	ref = task_ref if task_ref.startswith("refs/") else _task_ref(task_ref)
 	if ref not in repo.references:
@@ -390,13 +351,11 @@ def _comment_to_json(c: Comment) -> dict[str, Any]:
 
 @comment_router.get("/list", response_class=JSONResponse)
 def comment_list(
-	request: Request,
-	project: str = Query(..., description="Project path"),
+	project: ValidatedQueryProject,
 	board: str = Query(..., description="Board name"),
 	task: str = Query(..., description="Task ref (e.g. refs/tags/tasks/task_123)"),
 ) -> JSONResponse:
 	"""List all comments for a task."""
-	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	task_ref = task if task.startswith("refs/") else _task_ref(task)
 	t = get_task(repo, task_ref)
@@ -417,14 +376,12 @@ def comment_list(
 
 @comment_router.post("/create", response_class=JSONResponse)
 def comment_create(
-	request: Request,
-	project: str = Query(..., description="Project path"),
+	project: ValidatedQueryProject,
 	board: str = Query(..., description="Board name"),
 	task: str = Query(..., description="Task ref"),
 	content: str = Query(..., description="Comment content"),
 ) -> JSONResponse:
 	"""Create a new comment on a task."""
-	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	task_ref = task if task.startswith("refs/") else _task_ref(task)
 	t = get_task(repo, task_ref)
@@ -444,13 +401,12 @@ def comment_create(
 @comment_router.post("/update", response_class=JSONResponse)
 async def comment_update(
 	request: Request,
-	project: str = Query(..., description="Project path"),
+	project: ValidatedQueryProject,
 	board: str = Query(..., description="Board name"),
 	task: str = Query(..., description="Task ref (to update task.comments)"),
 	comment_oid: str = Query(..., alias="comment", description="Comment OID hex"),
 ) -> JSONResponse:
 	"""Update a comment (body: content). Writes new comment object and updates task.comments."""
-	_validate_project(project)
 	try:
 		body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
 	except Exception:
@@ -484,13 +440,11 @@ async def comment_update(
 
 @comment_router.post("/delete", response_class=JSONResponse)
 def comment_delete(
-	request: Request,
-	project: str = Query(..., description="Project path"),
+	project: ValidatedQueryProject,
 	board: str = Query(..., description="Board name"),
 	comment_oid: str = Query(..., alias="comment", description="Comment OID hex"),
 ) -> JSONResponse:
 	"""Delete a comment (object remains in ODB; caller may remove from task.comments)."""
-	_validate_project(project)
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
 	oid = pygit2.Oid(hex=comment_oid)
 	try:
