diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml
index a90058f26..2b4edeab4 100644
--- a/.github/workflows/backend.yml
+++ b/.github/workflows/backend.yml
@@ -45,7 +45,7 @@ jobs:
- name: Lint
working-directory: backend
- run: ruff check app tests
+ run: ruff check app tests scripts
- name: Docstring coverage
working-directory: backend
@@ -54,3 +54,9 @@ jobs:
- name: Test
working-directory: backend
run: pytest -q
+
+ - name: Week 1 real API e2e
+ working-directory: backend
+ env:
+ WORKSTREAM_DATABASE_URL: postgresql+asyncpg://workstream:workstream@localhost:5433/workstream
+ run: python scripts/week1_api_e2e.py
diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
new file mode 100644
index 000000000..841d345e8
--- /dev/null
+++ b/.github/workflows/frontend.yml
@@ -0,0 +1,30 @@
+name: Frontend
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
+ with:
+ persist-credentials: false
+
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: "24"
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install frontend
+ working-directory: frontend
+ run: npm ci
+
+ - name: Build frontend
+ working-directory: frontend
+ run: npm run build
diff --git a/.gitignore b/.gitignore
index 25482dc06..990b22b60 100644
--- a/.gitignore
+++ b/.gitignore
@@ -217,3 +217,7 @@ __marimo__/
# Local spreadsheet exports
sheets/
+
+# Frontend local artifacts
+frontend/node_modules/
+frontend/dist/
diff --git a/README.md b/README.md
index 250ebfd32..32f3a4c95 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,8 @@ Workstream turns that operating knowledge into reusable infrastructure.
- [Product Principles](docs/product_principles.md)
- [Product Brief](docs/product_brief.md)
- [First User Flows](docs/product_first_user_flows.md)
+- [Architecture Brief PDF](docs/architecture_brief/workstream_architecture_brief.pdf)
+- [Architecture Diagrams](docs/diagrams/README.md)
- [System Architecture](docs/architecture_system_architecture.md)
- [Data Model](docs/architecture_data_model.md)
- [Lifecycle State Machine](docs/architecture_lifecycle_state_machine.md)
@@ -116,6 +118,49 @@ The default local test URL is:
postgresql+asyncpg://workstream:workstream@localhost:5433/workstream
```
+## Local Frontend Demo
+
+The team demo UI lives in `frontend/`. It calls the real backend over HTTP through the Vite proxy and uses local Flow-style bearer tokens against the backend `flow` verifier.
+
+Start the backend for the demo:
+
+```bash
+cd backend
+WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream \
+WORKSTREAM_AUTH_PROVIDER=flow \
+WORKSTREAM_ENVIRONMENT=local \
+WORKSTREAM_FLOW_AUTH_ISSUER=https://auth.flow.local/demo \
+WORKSTREAM_FLOW_AUTH_AUDIENCE=workstream-demo \
+WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET=workstream-demo-local-secret \
+WORKSTREAM_ENABLE_DEMO_ROUTES=true \
+.venv/bin/alembic upgrade head
+
+WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream \
+WORKSTREAM_AUTH_PROVIDER=flow \
+WORKSTREAM_ENVIRONMENT=local \
+WORKSTREAM_FLOW_AUTH_ISSUER=https://auth.flow.local/demo \
+WORKSTREAM_FLOW_AUTH_AUDIENCE=workstream-demo \
+WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET=workstream-demo-local-secret \
+WORKSTREAM_ENABLE_DEMO_ROUTES=true \
+.venv/bin/python -m uvicorn app.main:create_app --factory --host 127.0.0.1 --port 8000
+```
+
+Start the frontend:
+
+```bash
+cd frontend
+npm install
+npm run dev -- --port 5173
+```
+
+Open:
+
+```text
+http://127.0.0.1:5173/
+```
+
+The demo runs the Week 1 path from project guide to locked submission using real API calls. The local demo worker-profile route is guarded by `WORKSTREAM_ENABLE_DEMO_ROUTES=true` and local/test environments only.
+
## Day-30 Success Standard
By day 30, Workstream runs a real internal task cycle with real people:
diff --git a/backend/app/adapters/auth/flow.py b/backend/app/adapters/auth/flow.py
index ce9954f96..6aaef43c4 100644
--- a/backend/app/adapters/auth/flow.py
+++ b/backend/app/adapters/auth/flow.py
@@ -2,10 +2,93 @@
from __future__ import annotations
+import base64
+import binascii
+import hashlib
+import hmac
+import json
+from datetime import UTC, datetime
+from typing import Any
+from uuid import NAMESPACE_URL, uuid5
+
from app.core.config import Settings
from app.interfaces.auth import AuthVerificationError
from app.schemas.auth import ActorContext
+LOCAL_FLOW_AUTH_ENVIRONMENTS = {"local", "dev", "development", "test"}
+
+
+def actor_id_from_flow_identity(external_issuer: str, external_subject: str) -> str:
+ """Build a stable Workstream actor id from Flow issuer and subject.
+
+ Args:
+ external_issuer: Issuer that signed the Flow token.
+ external_subject: Subject inside the Flow token.
+
+ Returns:
+ Deterministic actor id for the issuer and subject pair.
+ """
+ return str(uuid5(NAMESPACE_URL, f"{external_issuer}:{external_subject}"))
+
+
+def _decode_base64url(value: str) -> bytes:
+ """Decode one unpadded base64url token segment.
+
+ Args:
+ value: JWT segment to decode.
+
+ Returns:
+ Decoded bytes.
+
+ Raises:
+ AuthVerificationError: If the segment is malformed.
+ """
+ try:
+ padding = "=" * (-len(value) % 4)
+ return base64.urlsafe_b64decode(f"{value}{padding}")
+ except (binascii.Error, ValueError) as exc:
+ raise AuthVerificationError("malformed Flow token segment") from exc
+
+
+def _decode_json_segment(value: str) -> dict[str, Any]:
+ """Decode a JSON object from one base64url token segment.
+
+ Args:
+ value: JWT segment containing a JSON object.
+
+ Returns:
+ Decoded JSON object.
+
+ Raises:
+ AuthVerificationError: If the segment is not a JSON object.
+ """
+ try:
+ decoded = json.loads(_decode_base64url(value))
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
+ raise AuthVerificationError("malformed Flow token JSON") from exc
+ if not isinstance(decoded, dict):
+ raise AuthVerificationError("malformed Flow token JSON")
+ return decoded
+
+
+def _normalize_roles(value: Any) -> tuple[str, ...]:
+ """Normalize Flow role claims into a role tuple.
+
+ Args:
+ value: Role claim supplied as a list, tuple, set, or comma-separated
+ string.
+
+ Returns:
+ Normalized non-empty role names.
+ """
+ if isinstance(value, str):
+ raw_roles = value.split(",")
+ elif isinstance(value, list | tuple | set):
+ raw_roles = value
+ else:
+ raw_roles = ()
+ return tuple(str(role).strip() for role in raw_roles if str(role).strip())
+
class FlowAuthVerifier:
"""Production Flow token verifier boundary.
@@ -21,6 +104,13 @@ def __init__(self, settings: Settings) -> None:
settings: Application settings containing Flow auth configuration.
"""
self._issuer = settings.flow_auth_issuer
+ self._audience = settings.flow_auth_audience
+ self._local_hmac_secret = settings.flow_auth_local_hmac_secret
+ if (
+ self._local_hmac_secret
+ and settings.environment.strip().lower() not in LOCAL_FLOW_AUTH_ENVIRONMENTS
+ ):
+ raise RuntimeError("local Flow auth verifier cannot run outside local/test")
async def verify(self, token: str) -> ActorContext:
"""Verify a Flow bearer token.
@@ -29,9 +119,77 @@ async def verify(self, token: str) -> ActorContext:
token: Bearer token from the incoming request.
Raises:
- AuthVerificationError: Always raised until Flow verification is
- configured in a later chunk.
+ AuthVerificationError: If Flow verification is not configured or
+ the token is invalid.
"""
+ if self._local_hmac_secret:
+ return self._verify_local_hmac_token(token)
raise AuthVerificationError(
f"Flow token verification is not configured for issuer {self._issuer}"
)
+
+ def _verify_local_hmac_token(self, token: str) -> ActorContext:
+ """Verify a local Flow-compatible HMAC token for real API QA runs.
+
+ Args:
+ token: Bearer token supplied by an HTTP client.
+
+ Returns:
+ Trusted actor context derived from the token claims.
+
+ Raises:
+ AuthVerificationError: If token shape, signature, or claims fail.
+ """
+ parts = token.split(".")
+ if len(parts) != 3:
+ raise AuthVerificationError("malformed Flow token")
+
+ header_segment, payload_segment, signature_segment = parts
+ header = _decode_json_segment(header_segment)
+ if header.get("alg") != "HS256" or header.get("typ") != "JWT":
+ raise AuthVerificationError("unsupported Flow token header")
+
+ signed_content = f"{header_segment}.{payload_segment}".encode()
+ expected_signature = hmac.new(
+ self._local_hmac_secret.encode(),
+ signed_content,
+ hashlib.sha256,
+ ).digest()
+ supplied_signature = _decode_base64url(signature_segment)
+ if not hmac.compare_digest(expected_signature, supplied_signature):
+ raise AuthVerificationError("invalid Flow token signature")
+
+ claims = _decode_json_segment(payload_segment)
+ subject = claims.get("sub")
+ issuer = claims.get("iss")
+ audience = claims.get("aud")
+ if not isinstance(subject, str) or not subject:
+ raise AuthVerificationError("Flow token subject is required")
+ if issuer != self._issuer:
+ raise AuthVerificationError("Flow token issuer is invalid")
+ if audience != self._audience:
+ raise AuthVerificationError("Flow token audience is invalid")
+
+ now = datetime.now(UTC).timestamp()
+ expires_at = claims.get("exp")
+ not_before = claims.get("nbf")
+ if not isinstance(expires_at, int | float):
+ raise AuthVerificationError("Flow token expiry is required")
+ if expires_at <= now:
+ raise AuthVerificationError("Flow token is expired")
+ if not_before is not None and not isinstance(not_before, int | float):
+ raise AuthVerificationError("Flow token not-before claim is invalid")
+ if isinstance(not_before, int | float) and not_before > now:
+ raise AuthVerificationError("Flow token is not active")
+
+ return ActorContext(
+ actor_id=actor_id_from_flow_identity(issuer, subject),
+ external_subject=subject,
+ external_issuer=issuer,
+ email=claims.get("email") if isinstance(claims.get("email"), str) else None,
+ display_name=claims.get("name") if isinstance(claims.get("name"), str) else None,
+ roles=_normalize_roles(claims.get("roles", ())),
+ claim_snapshot=claims,
+ auth_source="flow",
+ is_dev_auth=False,
+ )
diff --git a/backend/app/api/router.py b/backend/app/api/router.py
index 279ba6534..ee1023fc4 100644
--- a/backend/app/api/router.py
+++ b/backend/app/api/router.py
@@ -5,6 +5,7 @@
from fastapi import APIRouter
from app.api.routes.auth import router as auth_router
+from app.api.routes.demo import router as demo_router
from app.api.routes.health import router as health_router
from app.modules.projects.router import router as projects_router
from app.modules.tasks.router import router as tasks_router
@@ -13,5 +14,6 @@
api_router.include_router(health_router)
api_router.include_router(health_router, prefix="/api/v1")
api_router.include_router(auth_router, prefix="/api/v1")
+api_router.include_router(demo_router, prefix="/api/v1")
api_router.include_router(projects_router, prefix="/api/v1")
api_router.include_router(tasks_router, prefix="/api/v1")
diff --git a/backend/app/api/routes/demo.py b/backend/app/api/routes/demo.py
new file mode 100644
index 000000000..666672061
--- /dev/null
+++ b/backend/app/api/routes/demo.py
@@ -0,0 +1,102 @@
+"""Local demo routes for exercising Week 1 flows from the frontend."""
+
+from __future__ import annotations
+
+from typing import Annotated
+from uuid import uuid4
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps.auth import get_current_actor
+from app.core.config import Settings, get_settings
+from app.db.session import get_db_session
+from app.modules.tasks.models import WorkerProfile
+from app.modules.tasks.repository import TaskRepository
+from app.schemas.auth import ActorContext
+
+DEMO_ENVIRONMENTS = {"local", "dev", "development", "test"}
+
+router = APIRouter(prefix="/demo", tags=["demo"])
+
+
+class DemoWorkerProfileRequest(BaseModel):
+ """Request schema for local demo worker profile activation."""
+
+ skill_tags: list[str] = Field(default_factory=lambda: ["stem", "proofs"])
+
+
+class DemoWorkerProfileResponse(BaseModel):
+ """Response schema for a local demo worker profile."""
+
+ id: str
+ actor_id: str
+ external_subject: str
+ external_issuer: str
+ display_name: str | None
+ email: str | None
+ skill_tags: list[str]
+ status: str
+
+
+def ensure_demo_routes_enabled(settings: Settings) -> None:
+ """Fail closed unless local demo routes are explicitly enabled.
+
+ Args:
+ settings: Runtime settings.
+
+ Raises:
+ HTTPException: If demo routes are disabled or used outside local/test.
+ """
+ if not settings.enable_demo_routes:
+ raise HTTPException(status_code=404, detail="demo routes are disabled")
+ if settings.environment.strip().lower() not in DEMO_ENVIRONMENTS:
+ raise HTTPException(status_code=404, detail="demo routes are disabled")
+
+
+@router.post("/worker-profile", response_model=DemoWorkerProfileResponse, status_code=201)
+async def activate_demo_worker_profile(
+ payload: DemoWorkerProfileRequest,
+ actor: Annotated[ActorContext, Depends(get_current_actor)],
+ session: Annotated[AsyncSession, Depends(get_db_session)],
+ settings: Annotated[Settings, Depends(get_settings)],
+) -> DemoWorkerProfileResponse:
+ """Create or update the current actor's local demo worker profile.
+
+ Args:
+ payload: Demo worker profile fields.
+ actor: Current Flow-authenticated actor.
+ session: Database session for the request.
+ settings: Runtime settings.
+
+ Returns:
+ Persisted worker profile for the current actor.
+ """
+ ensure_demo_routes_enabled(settings)
+ if "worker" not in actor.roles:
+ raise HTTPException(status_code=403, detail="worker role required")
+
+ profile = await TaskRepository(session).upsert_worker_profile(
+ WorkerProfile(
+ id=str(uuid4()),
+ actor_id=actor.actor_id,
+ external_subject=actor.external_subject,
+ external_issuer=actor.external_issuer,
+ display_name=actor.display_name,
+ email=actor.email,
+ skill_tags=payload.skill_tags,
+ status="active",
+ )
+ )
+ await session.commit()
+ return DemoWorkerProfileResponse(
+ id=profile.id,
+ actor_id=profile.actor_id,
+ external_subject=profile.external_subject,
+ external_issuer=profile.external_issuer,
+ display_name=profile.display_name,
+ email=profile.email,
+ skill_tags=profile.skill_tags,
+ status=profile.status,
+ )
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 4948df4bb..856f3ab7f 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -33,6 +33,9 @@ class Settings(BaseSettings):
dev_auth_display_name: str | None = None
dev_auth_roles: str = ""
flow_auth_issuer: str = "https://auth.flow.local"
+ flow_auth_audience: str = "workstream-api"
+ flow_auth_local_hmac_secret: str | None = None
+ enable_demo_routes: bool = False
model_config = SettingsConfigDict(
env_prefix="WORKSTREAM_",
diff --git a/backend/app/modules/tasks/schemas.py b/backend/app/modules/tasks/schemas.py
index b1fcb4e73..07ff2772b 100644
--- a/backend/app/modules/tasks/schemas.py
+++ b/backend/app/modules/tasks/schemas.py
@@ -5,8 +5,66 @@
from datetime import datetime
from decimal import Decimal
from typing import Any, Literal
-
-from pydantic import BaseModel, ConfigDict, Field
+from urllib.parse import unquote, urlparse
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+ALLOWED_STORAGE_URI_PREFIXES = ("local://", "s3://", "r2://")
+FORBIDDEN_URI_FRAGMENTS = (
+ "?",
+ "#",
+ "@",
+ "authorization=",
+ "credential=",
+ "password=",
+ "secret=",
+ "signature=",
+ "token=",
+ "x-amz-",
+)
+
+
+def validate_storage_reference(value: str | None) -> str | None:
+ """Validate a storage URI/reference accepted by submission packets.
+
+ Args:
+ value: Optional storage reference supplied by the client.
+
+ Returns:
+ Normalized value when it is a safe storage reference.
+
+ Raises:
+ ValueError: If the value is not an allowed storage reference.
+ """
+ if value is None:
+ return value
+ normalized = value.strip()
+ parsed = urlparse(normalized)
+ matching_prefix = next(
+ (prefix for prefix in ALLOWED_STORAGE_URI_PREFIXES if parsed.scheme == prefix[:-3]),
+ None,
+ )
+ if matching_prefix is None:
+ raise ValueError("uri must be a local, R2, or S3 object reference")
+ lowered_auth_components = f"{parsed.query}&{parsed.fragment}".lower()
+ has_signed_or_credential_fragment = any(
+ fragment in lowered_auth_components
+ for fragment in FORBIDDEN_URI_FRAGMENTS
+ if fragment not in {"?", "#", "@"}
+ )
+ if parsed.username or parsed.password or "@" in parsed.netloc:
+ raise ValueError("uri must not include credentials, query strings, or signed URL data")
+ if parsed.query or parsed.fragment or has_signed_or_credential_fragment:
+ raise ValueError("uri must not include credentials, query strings, or signed URL data")
+ reference = f"{parsed.netloc}{parsed.path}"
+ if not reference.strip("/"):
+ raise ValueError("uri must include an object reference")
+ if matching_prefix in {"s3://", "r2://"} and (not parsed.netloc or not parsed.path.strip("/")):
+ raise ValueError("uri must include a bucket and object key")
+ decoded_segments = unquote(reference).replace("\\", "/").split("/")
+ if any(segment in {"", ".", ".."} for segment in decoded_segments):
+ raise ValueError("uri must not include empty or traversal path segments")
+ return normalized
class TaskCreate(BaseModel):
@@ -56,6 +114,8 @@ class EvidenceItemCreate(BaseModel):
size_bytes: int | None = Field(default=None, ge=0)
metadata: dict[str, Any] = Field(default_factory=dict)
+ _validate_uri = field_validator("uri")(validate_storage_reference)
+
class ArtifactHashEntry(BaseModel):
"""Structured artifact hash entry supplied by a worker."""
@@ -80,6 +140,8 @@ class SubmissionCreate(BaseModel):
worker_attestation: str = Field(min_length=1)
evidence_items: list[EvidenceItemCreate] = Field(default_factory=list)
+ _validate_package_uri = field_validator("package_uri")(validate_storage_reference)
+
class TaskResponse(BaseModel):
"""Response schema for task records."""
diff --git a/backend/scripts/week1_api_e2e.py b/backend/scripts/week1_api_e2e.py
new file mode 100644
index 000000000..1101134bc
--- /dev/null
+++ b/backend/scripts/week1_api_e2e.py
@@ -0,0 +1,608 @@
+"""Run a real HTTP Week 1 API flow against Postgres and local Flow tokens."""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import hmac
+import json
+import os
+import socket
+import subprocess
+import sys
+import time
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from uuid import uuid4
+
+import httpx
+from alembic import command
+from alembic.config import Config
+
+DEFAULT_FLOW_ISSUER = "https://auth.flow.local/e2e"
+DEFAULT_FLOW_AUDIENCE = "workstream-api"
+
+
+def base64url_json(payload: dict) -> str:
+ """Encode a JSON payload as an unpadded base64url segment.
+
+ Args:
+ payload: JSON-serializable payload.
+
+ Returns:
+ Encoded JWT segment.
+ """
+ raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
+
+
+def base64url_bytes(payload: bytes) -> str:
+ """Encode bytes as an unpadded base64url segment.
+
+ Args:
+ payload: Raw bytes to encode.
+
+ Returns:
+ Encoded JWT segment.
+ """
+ return base64.urlsafe_b64encode(payload).rstrip(b"=").decode()
+
+
+def flow_settings(env: dict[str, str]) -> tuple[str, str, str]:
+ """Resolve local Flow settings from the runtime environment.
+
+ Args:
+ env: Runtime environment used by the API server and client.
+
+ Returns:
+ Issuer, audience, and HMAC secret used for local Flow tokens.
+ """
+ return (
+ env.get("WORKSTREAM_E2E_FLOW_ISSUER", DEFAULT_FLOW_ISSUER),
+ env.get("WORKSTREAM_E2E_FLOW_AUDIENCE", DEFAULT_FLOW_AUDIENCE),
+ env.get("WORKSTREAM_E2E_FLOW_SECRET", f"local-flow-e2e-{uuid4().hex}"),
+ )
+
+
+def issue_flow_token(
+ subject: str,
+ roles: list[str],
+ *,
+ issuer: str,
+ audience: str,
+ secret: str,
+) -> str:
+ """Issue a local Flow-compatible signed token for one QA actor.
+
+ Args:
+ subject: External Flow subject.
+ roles: Workstream roles granted by Flow for this actor.
+ issuer: Flow issuer claim.
+ audience: Flow audience claim.
+ secret: HMAC secret shared with the local Flow verifier.
+
+ Returns:
+ HMAC-signed bearer token consumed by ``FlowAuthVerifier``.
+ """
+ now = datetime.now(UTC)
+ header = base64url_json({"alg": "HS256", "typ": "JWT"})
+ payload = base64url_json(
+ {
+ "iss": issuer,
+ "aud": audience,
+ "sub": subject,
+ "email": f"{subject}@flow.local",
+ "name": subject.replace("-", " ").title(),
+ "roles": roles,
+ "iat": int(now.timestamp()),
+ "nbf": int((now - timedelta(seconds=5)).timestamp()),
+ "exp": int((now + timedelta(minutes=30)).timestamp()),
+ }
+ )
+ signed_content = f"{header}.{payload}".encode()
+ signature = hmac.new(secret.encode(), signed_content, hashlib.sha256).digest()
+ return f"{header}.{payload}.{base64url_bytes(signature)}"
+
+
+def auth_headers(token: str) -> dict[str, str]:
+ """Build a bearer authorization header.
+
+ Args:
+ token: Flow bearer token.
+
+ Returns:
+ HTTP authorization header.
+ """
+ return {"Authorization": f"Bearer {token}"}
+
+
+def project_root() -> Path:
+ """Return the backend project root.
+
+ Returns:
+ Absolute backend project path.
+ """
+ return Path(__file__).resolve().parents[1]
+
+
+def alembic_config() -> Config:
+ """Create Alembic configuration for the backend project.
+
+ Returns:
+ Alembic configuration pointing at the local backend migration folder.
+ """
+ root = project_root()
+ config = Config(str(root / "alembic.ini"))
+ config.set_main_option("script_location", str(root / "alembic"))
+ return config
+
+
+def find_free_port() -> int:
+ """Find an available localhost TCP port.
+
+ Returns:
+ Available port number.
+ """
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+
+def api_environment() -> dict[str, str]:
+ """Build environment variables for the real API server.
+
+ Returns:
+ Environment configured for local Flow auth and Postgres.
+ """
+ env = os.environ.copy()
+ env.setdefault(
+ "WORKSTREAM_DATABASE_URL",
+ "postgresql+asyncpg://workstream:workstream@localhost:5433/workstream",
+ )
+ flow_issuer, flow_audience, flow_secret = flow_settings(env)
+ env["WORKSTREAM_E2E_FLOW_SECRET"] = flow_secret
+ env["WORKSTREAM_AUTH_PROVIDER"] = "flow"
+ env["WORKSTREAM_ENVIRONMENT"] = "local"
+ env["WORKSTREAM_FLOW_AUTH_ISSUER"] = flow_issuer
+ env["WORKSTREAM_FLOW_AUTH_AUDIENCE"] = flow_audience
+ env["WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET"] = flow_secret
+ env["WORKSTREAM_ENABLE_DEMO_ROUTES"] = "true"
+ env["PYTHONPATH"] = str(project_root())
+ return env
+
+
+def start_api_server(port: int, env: dict[str, str]) -> tuple[subprocess.Popen, Path]:
+ """Start a real uvicorn server process.
+
+ Args:
+ port: Localhost port for the server.
+ env: Environment variables for the server process.
+
+ Returns:
+ Server process and log path.
+ """
+ log_path = project_root() / ".week1_api_e2e_server.log"
+ log_file = log_path.open("w", encoding="utf-8")
+ process = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "uvicorn",
+ "app.main:create_app",
+ "--factory",
+ "--host",
+ "127.0.0.1",
+ "--port",
+ str(port),
+ "--log-level",
+ "warning",
+ ],
+ cwd=project_root(),
+ env=env,
+ stdout=log_file,
+ stderr=subprocess.STDOUT,
+ text=True,
+ )
+ log_file.close()
+ return process, log_path
+
+
+async def wait_for_health(base_url: str, process: subprocess.Popen, log_path: Path) -> None:
+ """Wait until the real API server responds to health checks.
+
+ Args:
+ base_url: API server base URL.
+ process: Uvicorn subprocess.
+ log_path: Server log path used for diagnostics.
+
+ Raises:
+ RuntimeError: If the server exits or does not become healthy.
+ """
+ deadline = time.monotonic() + 20
+ async with httpx.AsyncClient(base_url=base_url, timeout=5) as client:
+ while time.monotonic() < deadline:
+ if process.poll() is not None:
+ raise RuntimeError(f"API server exited early:\n{log_path.read_text()}")
+ try:
+ response = await client.get("/api/v1/health")
+ if response.status_code == 200:
+ return
+ except httpx.HTTPError:
+ pass
+ await asyncio.sleep(0.25)
+ raise RuntimeError(f"API server did not become healthy:\n{log_path.read_text()}")
+
+
+async def request_json(
+ client: httpx.AsyncClient,
+ method: str,
+ path: str,
+ token: str | None = None,
+ payload: dict | None = None,
+ expected_status: int = 200,
+) -> dict | list:
+ """Call one API endpoint and assert its status.
+
+ Args:
+ client: Real HTTP client.
+ method: HTTP method.
+ path: API path.
+ token: Optional Flow bearer token.
+ payload: Optional JSON payload.
+ expected_status: Expected HTTP status code.
+
+ Returns:
+ Parsed JSON response body.
+
+ Raises:
+ AssertionError: If the response status does not match.
+ """
+ response = await client.request(
+ method,
+ path,
+ headers={} if token is None else auth_headers(token),
+ json=payload,
+ )
+ if response.status_code != expected_status:
+ try:
+ body = response.json()
+ except ValueError:
+ body = response.text
+ raise AssertionError(
+ f"{method} {path} expected {expected_status}, got {response.status_code}: {body}"
+ )
+ try:
+ body = response.json()
+ except ValueError as exc:
+ raise AssertionError(
+ f"{method} {path} returned non-JSON response: {response.text!r}"
+ ) from exc
+ if not isinstance(body, dict | list):
+ raise AssertionError(f"{method} {path} returned non-JSON payload: {body!r}")
+ print(f"PASS {method} {path} -> {response.status_code}")
+ return body
+
+
+def guide_payload(run_id: str) -> dict:
+ """Build a complete project guide payload.
+
+ Args:
+ run_id: Unique QA run id.
+
+ Returns:
+ Project guide payload.
+ """
+ return {
+ "version": "v1",
+ "content_markdown": f"# Real API Guide {run_id}",
+ "required_task_fields": [
+ "title",
+ "description",
+ "acceptance_criteria",
+ "required_evidence",
+ ],
+ "required_submission_fields": ["summary", "evidence", "worker_attestation"],
+ "task_instructions": "Complete the real API task.",
+ "output_requirements": "Submit artifact manifest and evidence.",
+ "acceptance_criteria": "Submission packet is complete.",
+ "rejection_criteria": "Evidence is missing.",
+ "reviewer_rubric": "Review packet completeness.",
+ "forbidden_actions": "No credentials or private source data.",
+ "required_skills": ["stem"],
+ "difficulty_scale": {"medium": 2},
+ "estimated_time_policy": {"default_minutes": 45},
+ "common_rejection_reasons": ["missing evidence"],
+ "evidence_policy": {"required": ["log"]},
+ "unacceptable_work_policy": "Copied or unverifiable work.",
+ "change_summary": "Initial real API guide",
+ "checker_policy": {
+ "required_checkers": ["check_policy_context_present"],
+ "warning_checkers": [],
+ "blocking_severities": ["high"],
+ },
+ "review_policy": {
+ "requires_second_review": False,
+ "allowed_decisions": ["accept", "needs_revision", "reject"],
+ "minimum_finding_fields": ["issue", "required_fix"],
+ "sla_hours": 24,
+ },
+ "revision_policy": {
+ "max_revision_rounds": 7,
+ "revision_deadline_hours": 48,
+ "auto_reject_after_limit": True,
+ "allowed_resubmission_states": ["needs_revision"],
+ "reviewer_reassignment_rule": "same reviewer preferred",
+ },
+ "payment_policy": {
+ "base_amount": "25.00",
+ "currency": "USD",
+ "payout_type": "fixed",
+ "revision_payment_rule": "none",
+ "rejection_payment_rule": "none",
+ "accepted_payment_rule": "pay base amount",
+ },
+ }
+
+
+async def exercise_week1_api(base_url: str, env: dict[str, str]) -> None:
+ """Run the real Project -> Task -> Submission Week 1 API flow.
+
+ Args:
+ base_url: Real API server base URL.
+ env: Runtime environment shared by the API server and token issuer.
+ """
+ flow_issuer, flow_audience, flow_secret = flow_settings(env)
+ run_id = uuid4().hex[:8]
+ manager_token = issue_flow_token(
+ f"real-api-project-manager-{run_id}",
+ ["project_manager"],
+ issuer=flow_issuer,
+ audience=flow_audience,
+ secret=flow_secret,
+ )
+ worker_subject = f"real-api-worker-{run_id}"
+ worker_token = issue_flow_token(
+ worker_subject,
+ ["worker"],
+ issuer=flow_issuer,
+ audience=flow_audience,
+ secret=flow_secret,
+ )
+ reviewer_token = issue_flow_token(
+ f"real-api-reviewer-{run_id}",
+ ["reviewer"],
+ issuer=flow_issuer,
+ audience=flow_audience,
+ secret=flow_secret,
+ )
+ invalid_token = (
+ issue_flow_token(
+ f"real-api-invalid-{run_id}",
+ ["worker"],
+ issuer=flow_issuer,
+ audience=flow_audience,
+ secret=flow_secret,
+ )[:-8]
+ + "tampered"
+ )
+
+ async with httpx.AsyncClient(base_url=base_url, timeout=10) as client:
+ await request_json(client, "GET", "/health")
+ await request_json(client, "GET", "/api/v1/health")
+ await request_json(client, "GET", "/api/v1/auth/me", invalid_token, expected_status=401)
+ manager = await request_json(client, "GET", "/api/v1/auth/me", manager_token)
+ assert manager["auth_source"] == "flow"
+ assert manager["is_dev_auth"] is False
+ assert manager["roles"] == ["project_manager"]
+
+ project = await request_json(
+ client,
+ "POST",
+ "/api/v1/projects",
+ manager_token,
+ {
+ "name": f"Week 1 Real API {run_id}",
+ "slug": f"week1-real-api-{run_id}",
+ "description": "Real Week 1 API lifecycle QA",
+ "base_amount": "25.00",
+ "currency": "USD",
+ },
+ 201,
+ )
+ await request_json(client, "GET", f"/api/v1/projects/{project['id']}", manager_token)
+
+ guide = await request_json(
+ client,
+ "POST",
+ f"/api/v1/projects/{project['id']}/guides",
+ manager_token,
+ guide_payload(run_id),
+ 201,
+ )
+ patched_guide = await request_json(
+ client,
+ "PATCH",
+ f"/api/v1/projects/{project['id']}/guides/{guide['id']}",
+ manager_token,
+ {"change_summary": "Patched before activation through real API"},
+ )
+ assert patched_guide["change_summary"] == "Patched before activation through real API"
+ active = await request_json(
+ client,
+ "POST",
+ f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate",
+ manager_token,
+ )
+ assert active["guide"]["version"] == "v1"
+ await request_json(client, "GET", f"/api/v1/projects/{project['id']}/active-guide", manager_token)
+
+ task = await request_json(
+ client,
+ "POST",
+ f"/api/v1/projects/{project['id']}/tasks",
+ manager_token,
+ {
+ "title": "Real API task",
+ "description": "Exercise the full Week 1 API lifecycle over HTTP.",
+ "task_type": "evaluation",
+ "difficulty": "medium",
+ "skill_tags": ["stem", "proofs"],
+ "estimated_time_minutes": 45,
+ "source_type": "manual",
+ "source_ref": f"real-api-{run_id}",
+ "source_payload_hash": f"sha256:source-{run_id}",
+ "acceptance_criteria": "Submission packet is complete.",
+ "rejection_criteria": "Evidence is missing.",
+ "required_files": ["answer.md"],
+ "required_evidence": ["checker log"],
+ },
+ 201,
+ )
+ await request_json(client, "GET", f"/api/v1/tasks/{task['id']}", manager_token)
+ await request_json(
+ client,
+ "POST",
+ f"/api/v1/tasks/{task['id']}/screen",
+ manager_token,
+ {"reason": "real API screening passed"},
+ )
+ await request_json(
+ client,
+ "POST",
+ f"/api/v1/tasks/{task['id']}/release",
+ manager_token,
+ {"reason": "real API release"},
+ )
+
+ worker = await request_json(client, "GET", "/api/v1/auth/me", worker_token)
+ assert worker["roles"] == ["worker"]
+ worker_profile = await request_json(
+ client,
+ "POST",
+ "/api/v1/demo/worker-profile",
+ worker_token,
+ {"skill_tags": ["stem", "proofs"]},
+ 201,
+ )
+ assert worker_profile["external_subject"] == worker_subject
+ assert worker_profile["external_issuer"] == flow_issuer
+ assert worker_profile["status"] == "active"
+ assert set(worker_profile["skill_tags"]) == {"stem", "proofs"}
+ await request_json(client, "GET", f"/api/v1/tasks/{task['id']}", worker_token)
+ claim = await request_json(
+ client,
+ "POST",
+ f"/api/v1/tasks/{task['id']}/claim",
+ worker_token,
+ {"reason": "real worker claim"},
+ )
+ await request_json(
+ client,
+ "POST",
+ f"/api/v1/tasks/{task['id']}/start",
+ worker_token,
+ {"reason": "real worker start"},
+ )
+ submission = await request_json(
+ client,
+ "POST",
+ f"/api/v1/tasks/{task['id']}/submissions",
+ worker_token,
+ {
+ "summary": "Real API packet completed.",
+ "package_uri": f"local://packages/token=build-{run_id}.tar.zst",
+ "package_hash": f"sha256:package-{run_id}",
+ "artifact_hash_manifest": [
+ {
+ "artifact": "answer.md",
+ "hash": f"sha256:answer-{run_id}",
+ "size_bytes": 128,
+ "notes": "real API artifact",
+ }
+ ],
+ "worker_attestation": "I confirm this packet follows the locked guide.",
+ "evidence_items": [
+ {
+ "type": "log",
+ "label": "real API evidence",
+ "uri": f"s3://workstream-e2e/reports/user@team-{run_id}.log",
+ "hash": f"sha256:evidence-{run_id}",
+ "size_bytes": 256,
+ "metadata": {"command": "week1_api_e2e"},
+ }
+ ],
+ },
+ 201,
+ )
+ assert submission["locked_guide_version"] == "v1"
+ await request_json(client, "GET", f"/api/v1/tasks/{task['id']}/submissions", worker_token)
+ await request_json(client, "GET", f"/api/v1/submissions/{submission['id']}", worker_token)
+ await request_json(
+ client,
+ "POST",
+ f"/api/v1/submissions/{submission['id']}/lock",
+ worker_token,
+ expected_status=403,
+ )
+ locked = await request_json(
+ client,
+ "POST",
+ f"/api/v1/submissions/{submission['id']}/lock",
+ manager_token,
+ )
+ assert locked["locked_at"] is not None
+ audit_events = await request_json(
+ client,
+ "GET",
+ f"/api/v1/tasks/{task['id']}/audit-events",
+ manager_token,
+ )
+ assert len(audit_events) >= 6
+ await request_json(
+ client,
+ "GET",
+ f"/api/v1/tasks/{task['id']}",
+ reviewer_token,
+ expected_status=403,
+ )
+
+ print("Week 1 real API e2e passed")
+ print(f"project_id={project['id']}")
+ print(f"guide_id={guide['id']}")
+ print(f"task_id={task['id']}")
+ print(f"assignment_id={claim['assignment']['id']}")
+ print(f"submission_id={submission['id']}")
+ print(f"submission_locked_at={locked['locked_at']}")
+
+
+async def main(env: dict[str, str]) -> None:
+ """Start the API server and exercise every Week 1 API.
+
+ Args:
+ env: Environment variables for the API server.
+ """
+ from app.db import session as db_session
+
+ await db_session.dispose_engine()
+
+ port = find_free_port()
+ base_url = f"http://127.0.0.1:{port}"
+ process, log_path = start_api_server(port, env)
+ try:
+ await wait_for_health(base_url, process, log_path)
+ await exercise_week1_api(base_url, env)
+ finally:
+ process.terminate()
+ try:
+ process.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait(timeout=10)
+
+
+if __name__ == "__main__":
+ api_env = api_environment()
+ os.environ.update(api_env)
+ command.upgrade(alembic_config(), "head")
+ asyncio.run(main(api_env))
diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py
index 84988664e..09b5d01f5 100644
--- a/backend/tests/test_tasks.py
+++ b/backend/tests/test_tasks.py
@@ -912,6 +912,351 @@ async def test_submission_versioning_creates_new_rows_and_preserves_v1(
assert denied.status_code == 404
+async def test_submission_uses_task_locked_context_after_new_guide_activation(
+ task_client: AsyncClient,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ project = await create_active_project(task_client)
+ started_task = await create_started_task(task_client, project["id"], monkeypatch)
+
+ set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject")
+ guide_v2 = await task_client.post(
+ f"/api/v1/projects/{project['id']}/guides",
+ headers=auth_headers(),
+ json=complete_guide_payload("v2"),
+ )
+ assert guide_v2.status_code == 201, guide_v2.text
+ activate_v2 = await task_client.post(
+ f"/api/v1/projects/{project['id']}/guides/{guide_v2.json()['id']}/activate",
+ headers=auth_headers(),
+ )
+ assert activate_v2.status_code == 200, activate_v2.text
+ assert activate_v2.json()["guide"]["version"] == "v2"
+
+ set_dev_actor(monkeypatch, roles="worker", subject="worker-one")
+ response = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ json=complete_submission_payload(),
+ )
+
+ assert response.status_code == 201, response.text
+ submission = response.json()
+ assert submission["locked_guide_version"] == "v1"
+ assert submission["locked_checker_policy_version"] == "v1"
+ task = await task_client.get(f"/api/v1/tasks/{started_task['id']}", headers=auth_headers())
+ assert task.status_code == 200, task.text
+ assert task.json()["locked_guide_version"] == "v1"
+
+
+async def test_locked_submission_can_only_be_replaced_by_new_version(
+ task_client: AsyncClient,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ project = await create_active_project(task_client)
+ started_task = await create_started_task(task_client, project["id"], monkeypatch)
+ v1 = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ json=complete_submission_payload(),
+ )
+ assert v1.status_code == 201, v1.text
+
+ set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject")
+ locked_v1 = await task_client.post(
+ f"/api/v1/submissions/{v1.json()['id']}/lock",
+ headers=auth_headers(),
+ )
+ assert locked_v1.status_code == 200, locked_v1.text
+
+ set_dev_actor(monkeypatch, roles="worker", subject="worker-one")
+ v2_payload = complete_submission_payload("sha256:package-replacement")
+ v2_payload["summary"] = "Replacement packet after locked v1."
+ v2_payload["artifact_hash_manifest"][0]["hash"] = "sha256:replacement-artifact"
+ v2 = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ json=v2_payload,
+ )
+
+ assert v2.status_code == 201, v2.text
+ assert v2.json()["version"] == 2
+ assert v2.json()["supersedes_submission_id"] == v1.json()["id"]
+ fetched_v1 = await task_client.get(
+ f"/api/v1/submissions/{v1.json()['id']}",
+ headers=auth_headers(),
+ )
+ assert fetched_v1.status_code == 200, fetched_v1.text
+ assert fetched_v1.json()["locked_at"] == locked_v1.json()["locked_at"]
+ assert fetched_v1.json()["package_hash"] == "sha256:package-v1"
+ assert fetched_v1.json()["artifact_hash_manifest"][0]["hash"] == "sha256:answer-v1"
+
+
+async def test_project_manager_cannot_submit_as_worker(
+ task_client: AsyncClient,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ project = await create_active_project(task_client)
+ started_task = await create_started_task(task_client, project["id"], monkeypatch)
+ set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject")
+
+ response = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ json=complete_submission_payload(),
+ )
+
+ assert response.status_code == 403
+
+
+async def test_submission_rejects_nested_manifest_and_evidence_injection(
+ task_client: AsyncClient,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ project = await create_active_project(task_client)
+ started_task = await create_started_task(task_client, project["id"], monkeypatch)
+ payload = complete_submission_payload()
+ payload["artifact_hash_manifest"][0]["locked_guide_version"] = "v999"
+ payload["evidence_items"][0]["submission_id"] = "attacker-controlled"
+
+ response = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ json=payload,
+ )
+
+ assert response.status_code == 422
+
+
+async def test_submission_rejects_signed_or_raw_external_uris(
+ task_client: AsyncClient,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ project = await create_active_project(task_client)
+ started_task = await create_started_task(task_client, project["id"], monkeypatch)
+ payload = complete_submission_payload()
+ payload["package_uri"] = "https://storage.example.test/package.tar?token=secret"
+
+ signed_package_response = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ json=payload,
+ )
+
+ assert signed_package_response.status_code == 422
+
+ payload = complete_submission_payload()
+ payload["evidence_items"][0]["uri"] = "file:///home/worker/private/evidence.log"
+ raw_file_response = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ json=payload,
+ )
+
+ assert raw_file_response.status_code == 422
+
+ payload = complete_submission_payload()
+ payload["package_uri"] = "local://"
+ empty_reference_response = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ json=payload,
+ )
+
+ assert empty_reference_response.status_code == 422
+
+ payload = complete_submission_payload()
+ payload["evidence_items"][0]["uri"] = "local://../private/evidence.log"
+ traversal_response = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ json=payload,
+ )
+
+ assert traversal_response.status_code == 422
+
+
+async def test_submitted_task_rejects_earlier_lifecycle_actions_without_new_audit(
+ task_client: AsyncClient,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ project = await create_active_project(task_client)
+ started_task = await create_started_task(task_client, project["id"], monkeypatch)
+ submitted = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ json=complete_submission_payload(),
+ )
+ assert submitted.status_code == 201, submitted.text
+ audit_before = await task_client.get(
+ f"/api/v1/tasks/{started_task['id']}/audit-events",
+ headers=auth_headers(),
+ )
+ assert audit_before.status_code == 200, audit_before.text
+
+ set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject")
+ screen = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/screen",
+ headers=auth_headers(),
+ json={"reason": "try rescreen"},
+ )
+ release = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/release",
+ headers=auth_headers(),
+ json={"reason": "try release"},
+ )
+ set_dev_actor(monkeypatch, roles="worker", subject="worker-one")
+ claim = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/claim",
+ headers=auth_headers(),
+ json={"reason": "try claim"},
+ )
+ start = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/start",
+ headers=auth_headers(),
+ json={"reason": "try start"},
+ )
+ audit_after = await task_client.get(
+ f"/api/v1/tasks/{started_task['id']}/audit-events",
+ headers=auth_headers(),
+ )
+
+ assert screen.status_code == 409
+ assert release.status_code == 409
+ assert claim.status_code == 409
+ assert start.status_code == 409
+ assert audit_after.status_code == 200, audit_after.text
+ assert len(audit_after.json()) == len(audit_before.json())
+
+
+async def test_concurrent_submission_posts_return_clean_version_outcomes(
+ task_client: AsyncClient,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ project = await create_active_project(task_client)
+ started_task = await create_started_task(task_client, project["id"], monkeypatch)
+
+ async def post_submission(package_hash: str) -> int:
+ payload = complete_submission_payload(package_hash)
+ response = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ json=payload,
+ )
+ return response.status_code
+
+ statuses = await asyncio.gather(
+ post_submission("sha256:concurrent-one"),
+ post_submission("sha256:concurrent-two"),
+ )
+ listed = await task_client.get(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ )
+ task = await task_client.get(f"/api/v1/tasks/{started_task['id']}", headers=auth_headers())
+
+ assert set(statuses).issubset({201, 409})
+ assert statuses.count(201) >= 1
+ assert listed.status_code == 200, listed.text
+ assert [submission["version"] for submission in listed.json()] == list(
+ range(1, statuses.count(201) + 1)
+ )
+ assert task.status_code == 200, task.text
+ assert task.json()["status"] == "submitted"
+
+
+async def test_cross_worker_cannot_list_submissions_or_audit_after_submit(
+ task_client: AsyncClient,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ project = await create_active_project(task_client)
+ started_task = await create_started_task(task_client, project["id"], monkeypatch)
+ created = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ json=complete_submission_payload(),
+ )
+ assert created.status_code == 201, created.text
+ await seed_worker_profile("worker-two")
+ set_dev_actor(monkeypatch, roles="worker", subject="worker-two")
+
+ listed = await task_client.get(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ )
+ audit = await task_client.get(
+ f"/api/v1/tasks/{started_task['id']}/audit-events",
+ headers=auth_headers(),
+ )
+
+ assert listed.status_code == 404
+ assert audit.status_code == 404
+
+
+@pytest.mark.parametrize("role", ["reviewer", "finance", "auditor"])
+async def test_future_roles_cannot_view_week1_task_or_submissions(
+ task_client: AsyncClient,
+ monkeypatch: pytest.MonkeyPatch,
+ role: str,
+) -> None:
+ project = await create_active_project(task_client)
+ started_task = await create_started_task(task_client, project["id"], monkeypatch)
+ created = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ json=complete_submission_payload(),
+ )
+ assert created.status_code == 201, created.text
+ set_dev_actor(monkeypatch, roles=role, subject=f"{role}-subject")
+
+ task_read = await task_client.get(f"/api/v1/tasks/{started_task['id']}", headers=auth_headers())
+ submissions_read = await task_client.get(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ )
+
+ assert task_read.status_code == 403
+ assert submissions_read.status_code == 403
+
+
+async def test_database_blocks_task_locked_context_mutation_after_submission(
+ task_client: AsyncClient,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ project = await create_active_project(task_client)
+ started_task = await create_started_task(task_client, project["id"], monkeypatch)
+ created = await task_client.post(
+ f"/api/v1/tasks/{started_task['id']}/submissions",
+ headers=auth_headers(),
+ json=complete_submission_payload(),
+ )
+ assert created.status_code == 201, created.text
+
+ set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject")
+ guide_v2 = await task_client.post(
+ f"/api/v1/projects/{project['id']}/guides",
+ headers=auth_headers(),
+ json=complete_guide_payload("v2"),
+ )
+ assert guide_v2.status_code == 201, guide_v2.text
+ activate_v2 = await task_client.post(
+ f"/api/v1/projects/{project['id']}/guides/{guide_v2.json()['id']}/activate",
+ headers=auth_headers(),
+ )
+ assert activate_v2.status_code == 200, activate_v2.text
+
+ async with db_session.get_session_factory()() as session:
+ task = await session.get(WorkstreamTask, started_task["id"])
+ assert task is not None
+ task.locked_guide_version = "v2"
+ task.locked_checker_policy_version = "v2"
+ task.locked_review_policy_version = "v2"
+ task.locked_revision_policy_version = "v2"
+ task.locked_payment_policy_version = "v2"
+ with pytest.raises(IntegrityError):
+ await session.commit()
+
+
async def test_lock_submission_requires_operator_and_latest_version(
task_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
diff --git a/docs/architecture_brief/README.md b/docs/architecture_brief/README.md
new file mode 100644
index 000000000..8e0ff3e7e
--- /dev/null
+++ b/docs/architecture_brief/README.md
@@ -0,0 +1,17 @@
+# Workstream Architecture Brief
+
+This folder contains the shareable Workstream architecture brief.
+
+- Source: [workstream_architecture_brief.md](workstream_architecture_brief.md)
+- PDF: [workstream_architecture_brief.pdf](workstream_architecture_brief.pdf)
+- Render script: [render_pdf.sh](render_pdf.sh)
+
+The brief uses the C4-PlantUML diagrams from `docs/diagrams/` and packages them into a single PDF for team review.
+
+## Render
+
+```bash
+docs/architecture_brief/render_pdf.sh
+```
+
+The script regenerates image assets from the current diagram SVGs, renders the lifecycle sequence asset when PlantUML is available, then produces `workstream_architecture_brief.pdf`.
diff --git a/docs/architecture_brief/brief.css b/docs/architecture_brief/brief.css
new file mode 100644
index 000000000..9c085584a
--- /dev/null
+++ b/docs/architecture_brief/brief.css
@@ -0,0 +1,173 @@
+@page {
+ size: Letter;
+ margin: 0.65in;
+}
+
+@page landscape {
+ size: Letter landscape;
+ margin: 0.45in;
+}
+
+:root {
+ color: #0b1720;
+ font-family: Inter, Arial, sans-serif;
+ font-size: 10.5pt;
+ line-height: 1.42;
+}
+
+body {
+ margin: 0;
+}
+
+h1,
+h2,
+h3 {
+ color: #0b2942;
+ line-height: 1.15;
+}
+
+h1 {
+ font-size: 28pt;
+ margin: 0 0 0.35in;
+}
+
+h2 {
+ border-bottom: 1px solid #d0d7de;
+ font-size: 18pt;
+ margin: 0 0 0.18in;
+ padding-bottom: 0.08in;
+}
+
+h3 {
+ font-size: 13pt;
+ margin: 0.2in 0 0.06in;
+}
+
+p {
+ margin: 0 0 0.11in;
+}
+
+ul {
+ margin: 0.04in 0 0.16in 0.22in;
+ padding: 0;
+}
+
+li {
+ margin: 0.03in 0;
+}
+
+table {
+ border-collapse: collapse;
+ font-size: 9.3pt;
+ margin: 0.1in 0 0.18in;
+ width: 100%;
+}
+
+th,
+td {
+ border: 1px solid #d0d7de;
+ padding: 0.07in;
+ vertical-align: top;
+}
+
+th {
+ background: #f6f8fa;
+ color: #0b2942;
+}
+
+code {
+ background: #f6f8fa;
+ border: 1px solid #d0d7de;
+ border-radius: 3px;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ font-size: 9pt;
+ padding: 0.01in 0.03in;
+}
+
+.subtitle {
+ color: #495057;
+ font-size: 14pt;
+ margin-bottom: 0.25in;
+}
+
+.meta {
+ color: #57606a;
+ font-size: 9.5pt;
+ margin-top: 0.2in;
+}
+
+.callout {
+ background: #f8fbff;
+ border-left: 4px solid #1c7ed6;
+ margin: 0.12in 0 0.18in;
+ padding: 0.1in 0.14in;
+}
+
+.boundary {
+ background: #fff9db;
+ border-left: 4px solid #f08c00;
+ margin: 0.12in 0 0.18in;
+ padding: 0.1in 0.14in;
+}
+
+.diagram {
+ background: #ffffff;
+ border: 1px solid #d0d7de;
+ margin: 0.12in 0 0.18in;
+ padding: 0.08in;
+ text-align: center;
+}
+
+.diagram img {
+ display: block;
+ margin: 0 auto;
+ max-height: 7.1in;
+ max-width: 100%;
+ object-fit: contain;
+}
+
+.wide img {
+ max-height: 6.45in;
+}
+
+.sequence img {
+ max-height: 5.55in;
+}
+
+.landscape {
+ break-after: page;
+ break-before: page;
+ page: landscape;
+}
+
+.landscape h2 {
+ font-size: 16pt;
+ margin-bottom: 0.1in;
+}
+
+.landscape p {
+ font-size: 9.5pt;
+}
+
+.landscape .diagram img {
+ max-height: 5.85in;
+}
+
+.page-break {
+ break-after: page;
+}
+
+.cover {
+ align-items: flex-start;
+ display: flex;
+ flex-direction: column;
+ min-height: 8.1in;
+ justify-content: center;
+}
+
+.cover .line {
+ background: #1c7ed6;
+ height: 5px;
+ margin: 0.2in 0;
+ width: 1.4in;
+}
diff --git a/docs/architecture_brief/images/backend_v01_components.png b/docs/architecture_brief/images/backend_v01_components.png
new file mode 100644
index 000000000..b01e801d9
Binary files /dev/null and b/docs/architecture_brief/images/backend_v01_components.png differ
diff --git a/docs/architecture_brief/images/future_identity_payment_reputation.png b/docs/architecture_brief/images/future_identity_payment_reputation.png
new file mode 100644
index 000000000..735c04415
Binary files /dev/null and b/docs/architecture_brief/images/future_identity_payment_reputation.png differ
diff --git a/docs/architecture_brief/images/task_lifecycle_sequence.png b/docs/architecture_brief/images/task_lifecycle_sequence.png
new file mode 100644
index 000000000..e36488c40
Binary files /dev/null and b/docs/architecture_brief/images/task_lifecycle_sequence.png differ
diff --git a/docs/architecture_brief/images/workstream_context.png b/docs/architecture_brief/images/workstream_context.png
new file mode 100644
index 000000000..a4ad2dcaf
Binary files /dev/null and b/docs/architecture_brief/images/workstream_context.png differ
diff --git a/docs/architecture_brief/images/workstream_v01_container.png b/docs/architecture_brief/images/workstream_v01_container.png
new file mode 100644
index 000000000..9bf9499ba
Binary files /dev/null and b/docs/architecture_brief/images/workstream_v01_container.png differ
diff --git a/docs/architecture_brief/render_pdf.sh b/docs/architecture_brief/render_pdf.sh
new file mode 100755
index 000000000..0757df90d
--- /dev/null
+++ b/docs/architecture_brief/render_pdf.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+mkdir -p images
+
+convert_diagram() {
+ local source="$1"
+ local target="$2"
+ convert -background white -density 180 "$source" -resize '2400x2400>' "$target"
+}
+
+convert_diagram ../diagrams/rendered/workstream_context.svg images/workstream_context.png
+convert_diagram ../diagrams/rendered/workstream_v01_container.svg images/workstream_v01_container.png
+convert_diagram ../diagrams/rendered/backend_v01_components.svg images/backend_v01_components.png
+convert_diagram ../diagrams/rendered/future_identity_payment_reputation.svg images/future_identity_payment_reputation.png
+
+if command -v plantuml >/dev/null 2>&1; then
+ plantuml -tpng task_lifecycle_sequence.puml
+elif [[ -n "${PLANTUML_JAR:-}" ]]; then
+ java -jar "$PLANTUML_JAR" -tpng task_lifecycle_sequence.puml
+else
+ echo "PlantUML is required to regenerate task_lifecycle_sequence.png" >&2
+ echo "Set PLANTUML_JAR=/path/to/plantuml.jar or install plantuml." >&2
+ exit 1
+fi
+mv task_lifecycle_sequence.png images/task_lifecycle_sequence.png
+
+pandoc workstream_architecture_brief.md \
+ --standalone \
+ --from markdown+raw_html \
+ --pdf-engine=weasyprint \
+ --css brief.css \
+ --metadata title="Workstream Architecture Brief" \
+ --resource-path=".:images" \
+ --output workstream_architecture_brief.pdf
+
+echo "Rendered docs/architecture_brief/workstream_architecture_brief.pdf"
diff --git a/docs/architecture_brief/task_lifecycle_sequence.puml b/docs/architecture_brief/task_lifecycle_sequence.puml
new file mode 100644
index 000000000..c81c4770d
--- /dev/null
+++ b/docs/architecture_brief/task_lifecycle_sequence.puml
@@ -0,0 +1,77 @@
+@startuml task_lifecycle_sequence
+title Workstream v0.1 Task Lifecycle
+
+skinparam backgroundColor #FFFFFF
+skinparam shadowing false
+skinparam roundcorner 8
+skinparam defaultFontName Arial
+skinparam defaultFontSize 13
+skinparam SequenceArrowThickness 1.2
+skinparam ParticipantBorderColor #1C7ED6
+skinparam ParticipantBackgroundColor #E7F5FF
+skinparam ParticipantFontColor #0B2942
+skinparam ActorBorderColor #868E96
+skinparam ActorBackgroundColor #F1F3F5
+skinparam DatabaseBorderColor #0CA678
+skinparam DatabaseBackgroundColor #E6FCF5
+
+actor "Project\nManager" as PM
+actor "Human-Agent\nContributor" as Worker
+actor Reviewer
+participant "React UI" as UI
+participant "FastAPI\nBackend" as API
+participant "Flow Auth\nVerifier" as Auth
+database "Postgres" as DB
+participant "Storage\nAbstraction" as Storage
+participant "Checker\nRunner" as Checks
+
+PM -> UI : create project, guide, policies
+UI -> API : POST project / guide / policies
+API -> Auth : verify Flow token
+Auth --> API : ActorContext(project_manager)
+API -> DB : persist draft guide and policy context
+
+PM -> UI : activate guide
+UI -> API : POST activate guide
+API -> DB : validate policy context and mark active
+
+PM -> UI : create and screen task
+UI -> API : POST task, screen, release
+API -> DB : lock guide and policy versions
+API -> DB : DRAFT -> SCREENING -> READY
+
+Worker -> UI : claim task
+UI -> API : POST claim / start
+API -> Auth : verify Flow token
+Auth --> API : ActorContext(worker)
+API -> DB : create assignment and move in progress
+
+Worker -> UI : submit packet
+UI -> API : POST submission packet
+API -> Storage : store/read artifact references
+API -> DB : create immutable submission version
+
+API -> Checks : run automated checks async
+Checks -> Storage : read artifacts
+Checks -> DB : persist checker results
+
+Reviewer -> UI : submit review decision
+UI -> API : accept / needs_revision / reject
+API -> Auth : verify Flow token
+Auth --> API : ActorContext(reviewer)
+API -> DB : store review decision
+
+alt needs_revision
+ API -> DB : create revision requirements
+ Worker -> UI : submit revision replay
+ UI -> API : new submission version
+ API -> Checks : rerun checks
+else accept
+ API -> DB : create contribution record
+ API -> DB : create payment record
+ API -> DB : create reputation event
+else reject
+ API -> DB : store rejection findings
+ API -> DB : apply payment/reputation policy effects
+end
+@enduml
diff --git a/docs/architecture_brief/workstream_architecture_brief.md b/docs/architecture_brief/workstream_architecture_brief.md
new file mode 100644
index 000000000..2c81d4d86
--- /dev/null
+++ b/docs/architecture_brief/workstream_architecture_brief.md
@@ -0,0 +1,195 @@
+
+
+# Workstream Architecture Brief
+
+
+
+
Flow task evaluation and contribution infrastructure
+
+Workstream manages project guides, task queues, submission packets, automated checks, reviewer routing, evaluation sprints, revision loops, contribution records, payment status, and reputation signals.
+
+Workstream is how Flow measures, certifies, and coordinates useful human-agent work.
+
+
Scope: v0.1 first 30 days, with future adapter context for identity, task contracts, settlement, and reputation.
+
+
+
+
+
+## Executive Summary
+
+Workstream is the operating system for useful work inside Flow. It does not try to own every possible execution environment. Instead, it gives every project a guide, every task a locked policy context, every submission an evidence packet, every review a canonical decision, and every accepted task a contribution record before payment and reputation events.
+
+The first 30 days are focused on proving the internal lifecycle:
+
+```text
+Project Guide -> Task Queue -> Submission Packet -> Checks -> Review
+-> Revision / Acceptance / Rejection -> Contribution Record
+-> Payment Record -> Reputation Event
+```
+
+
+Current v0.1 is backend-first and internal-loop-first. External source adapters, agent identity writes, task escrow, x402 payment requests, OmniClaw settlement, USDC payouts, public marketplace flows, and automated routing remain adapter boundaries until the internal evaluation loop works with real tasks.
+
+
+## Architecture Principles
+
+| Principle | Meaning |
+| --- | --- |
+| Source-agnostic, manual-first | v0.1 accepts manual, markdown, or CSV-controlled intake. Future origins normalize into the same task contract. |
+| Flow auth boundary | Workstream verifies Flow-issued tokens. It does not own login, signup, password reset, password storage, or primary sessions. |
+| Modular monolith | FastAPI remains one deployable backend while keeping routers, services, repositories, ports, and adapters separate. |
+| Postgres record database | Local, CI, and production-like development use Postgres as the record database. |
+| Object-storage abstraction | Local filesystem storage is allowed only behind an R2/S3-compatible storage interface. |
+| Async-first execution | Long-running checker work does not block request/response paths. |
+| Contribution before payment | Accepted work creates a durable contribution record before payment status or reputation events. |
+
+
+
+## C1: System Context
+
+The context diagram shows Workstream as one system inside the broader Flow ecosystem. Workstream owns evaluation and records. It does not own human identity, future agent identity standards, task escrow, settlement rails, or external task origins.
+
+
+
+
+
+### What This Means
+
+- Project managers, contributors, reviewers, and operators interact with Workstream.
+- Flow identity remains the human identity and auth source.
+- Postgres is the record database.
+- Storage sits behind an object-storage abstraction.
+- Future origins and protocol rails connect through adapters, not by becoming core Workstream logic.
+
+
+
+
+
+## C2: v0.1 Container View
+
+The container view shows the first 30-day implementation. It is intentionally small: React + Vite for the internal operations UI, FastAPI for the backend, Postgres for records, a storage interface for artifacts, and an async checker/job boundary.
+
+
+
+
+
+
+
+### Container Responsibilities
+
+| Container | Responsibility |
+| --- | --- |
+| React + Vite UI | Internal operations dashboard for project, task, submission, review, payment status, and reputation workflows. |
+| FastAPI backend | API contracts, workflow rules, auth dependency, lifecycle guards, module orchestration, and audit writes. |
+| Async job boundary | Non-blocking checker and background work. FastAPI background tasks are acceptable early; durable workers come when retries or distributed execution are needed. |
+| Checker runner | Executes automated checks and stores checker results. |
+| Storage interface | Keeps file/evidence semantics stable while local storage can later move to R2/S3-compatible object storage. |
+| Postgres | Durable record database for the full Workstream lifecycle. |
+
+
+
+## C3: Backend Component View
+
+The backend component view zooms into the FastAPI container. It shows how the modular monolith stays clean without becoming a distributed system too early.
+
+
+
+
+
+
+
+### Backend Boundaries
+
+| Boundary | Responsibility |
+| --- | --- |
+| HTTP + auth boundary | Routers handle HTTP only. Actor resolution, permission checks, and Pydantic request/response validation stay at the boundary. |
+| Workflow services | Project guide, task queue, submission, checker, review/revision, and contribution/payment/reputation services own business rules. |
+| Shared domain rules | Lifecycle guards and audit writes stay shared instead of being scattered through routers. |
+| Persistence boundary | Repositories own SQLAlchemy async persistence and Postgres access. |
+| External ports/adapters | Flow auth, storage, and checker execution stay behind interfaces. |
+
+
+
+## Lifecycle Sequence
+
+The sequence below shows the narrow v0.1 loop the system must prove before expansion.
+
+
+
+
+
+### Lifecycle Invariants
+
+- A task cannot enter `READY` without locked guide, checker, review, revision, and payment policy context.
+- A worker submission creates a new immutable submission version; locked artifacts are not edited in place.
+- Review decisions are exactly `accept`, `needs_revision`, or `reject`.
+- `needs_revision` starts a revision loop and must replay prior findings.
+- Accepted work creates a contribution record before payment or reputation records.
+- Payment status is separate from task acceptance.
+
+
+
+
+
+## Future Identity, Task Contract, Settlement, And Reputation
+
+This view explains the broader architecture direction without moving it into v0.1 scope.
+
+
+
+
+
+
+
+### Future Separation Of Concern
+
+| Concern | Owner |
+| --- | --- |
+| Human identity and auth | Flow identity layer |
+| Agent identity | ERC-8004 |
+| Agent reputation read/write | ERC-8004 through a future Workstream adapter |
+| Task contract and escrow reference | ERC-8183 |
+| Evaluation lifecycle | Workstream |
+| Accepted-work certification | Workstream contribution record |
+| Payment policy and payment status | Workstream payment record |
+| Payment request and settlement execution | x402, OmniClaw, and USDC settlement rails |
+
+
+Future ERC-8004, ERC-8183, x402, OmniClaw, and USDC integrations do not replace Workstream. They use Workstream records. Workstream remains the evaluation, acceptance, contribution, payment-status, and reputation-signal system.
+