From ef2cd187c4a1b4fc2b3cbb7d4d8d563ff9e50bb0 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 17:02:39 +0100 Subject: [PATCH 01/25] Implement actor identity profile registry --- .agent-loop/REVIEW_LOG.md | 33 ++ .agent-loop/WORK_QUEUE.md | 12 +- ...-001-11-actor-identity-profile-registry.md | 14 +- README.md | 6 +- .../0012_actor_identity_profile_registry.py | 302 ++++++++++++ backend/app/api/deps/auth.py | 21 + backend/app/api/routes/auth.py | 4 +- backend/app/api/routes/demo.py | 83 +--- backend/app/db/models.py | 3 +- backend/app/modules/actors/__init__.py | 2 + backend/app/modules/actors/models.py | 96 ++++ backend/app/modules/actors/repository.py | 154 ++++++ backend/app/modules/actors/schemas.py | 69 +++ backend/app/modules/actors/service.py | 389 +++++++++++++++ backend/app/modules/tasks/models.py | 44 +- backend/app/modules/tasks/repository.py | 119 +---- backend/app/modules/tasks/router.py | 27 +- backend/app/modules/tasks/schemas.py | 42 -- backend/app/modules/tasks/service.py | 84 +--- backend/scripts/week1_api_e2e.py | 4 +- backend/scripts/week1_dry_run.py | 36 +- backend/scripts/week2_api_e2e.py | 6 +- backend/tests/test_actors.py | 354 +++++++++++++ backend/tests/test_alembic.py | 465 ++++++++++++++++++ backend/tests/test_auth.py | 31 ++ backend/tests/test_tasks.py | 366 ++++++++++---- demos/week1_api_demo_ui/src/App.tsx | 10 +- docs/architecture_data_model.md | 46 +- docs/architecture_system_architecture.md | 9 +- docs/glossary.md | 12 +- docs/operations_roles_permissions.md | 4 + docs/spec_chunk_2_auth_actor_boundary.md | 29 +- docs/spec_chunk_4_task_queue_assignment.md | 12 +- .../LOCAL_VALIDATION_NOTES.md | 8 +- .../terminal_benchmark_api_e2e.py | 7 +- 35 files changed, 2355 insertions(+), 548 deletions(-) create mode 100644 backend/alembic/versions/0012_actor_identity_profile_registry.py create mode 100644 backend/app/modules/actors/__init__.py create mode 100644 backend/app/modules/actors/models.py create mode 100644 backend/app/modules/actors/repository.py create mode 100644 backend/app/modules/actors/schemas.py create mode 100644 backend/app/modules/actors/service.py create mode 100644 backend/tests/test_actors.py diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index e0f5685..5579ddf 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -293,3 +293,36 @@ API drill. Evidence: `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-10-internal-review-evidence.md` External review response: `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-10-external-review-response.md` + +## WS-POL-001-11 + +Status: implementation branch prepared for PR review on 2026-07-06. + +Branch: `codex/ws-pol-001-11-actor-profile-registry-impl` + +Reviewed implementation SHA: see the internal review evidence file for the +exact reviewed code SHA. + +Required reviewer tracks: + +- senior engineering +- QA/test +- security/auth +- product/ops +- architecture +- docs +- reuse/dedup +- test delta + +Result: PASS after fixes from internal review. + +Scope: local `ActorIdentity` and shared `ActorProfile` registry for verified +Flow actors, migration/backfill/removal of old worker/reviewer profile stores, +explicit actor-registration dependency, worker profile activation through the +canonical worker endpoint, claim eligibility requiring verified worker token +role plus active worker profile, stale demo route cleanup, and Flow +issuer-plus-subject identity compatibility wording. + +Evidence: `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md` + +External review response: pending PR review. diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 91b577f..9154de9 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-POL-001-11` | Actor Identity And Profile Registry Contract | L1 | Contract-only branch active; implementation waits for human review | +| `WS-POL-001-11` | Actor Identity And Profile Registry | L1 | Implementation branch active; internal review passed; preparing PR evidence | ## Completed @@ -26,11 +26,11 @@ ## Proposed Next -The next implementation chunk should create local Workstream actor identity and -actor profile registries for verified Flow actors. It must not turn Workstream -into the auth provider, and persisted profiles must not become permission -authority. After that merge, rerun the Terminal Benchmark live API drill through -real HTTP calls. +After `WS-POL-001-11` is externally reviewed and merged, rerun the Terminal +Benchmark live API drill through real HTTP calls using +`POST /api/v1/workers/me/profile` for worker profile setup. The next product +chunk should stay bounded to issues discovered by that drill or the approved +WS-POL chunk map. ## Blocked diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md index 8e79b9b..0d267ba 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md @@ -84,6 +84,9 @@ backend/scripts/week1_dry_run.py backend/scripts/week1_api_e2e.py backend/scripts/week2_api_e2e.py examples/terminal_benchmark/terminal_benchmark_api_e2e.py +examples/terminal_benchmark/LOCAL_VALIDATION_NOTES.md +README.md +demos/week1_api_demo_ui/src/App.tsx docs/architecture_data_model.md docs/architecture_lockdown.md docs/architecture_system_architecture.md @@ -117,7 +120,9 @@ blockchain, ERC-8004, ERC-8183, settlement, marketplace, or external source adap Demo/backend script/example edits are allowed only to remove, retire, or rewire stale `WorkerProfile`/`ReviewerProfile` imports and stale `/api/v1/demo/worker-profile` calls that would break application import or -local evidence scripts after the shared actor profile migration. +local evidence scripts after the shared actor profile migration. The Week 1 demo +README/UI may be updated only to point worker profile setup at +`POST /api/v1/workers/me/profile`. ## Expected design @@ -381,6 +386,11 @@ profile sources of truth. `examples/terminal_benchmark/terminal_benchmark_api_e2e.py` no longer call `/api/v1/demo/worker-profile`; they either use `POST /api/v1/workers/me/profile` or are explicitly retired. +- [ ] Terminal Benchmark validation notes no longer describe worker profile + setup as demo bootstrap; they identify the canonical worker profile API. +- [ ] Week 1 demo README/UI no longer advertise or call + `/api/v1/demo/worker-profile`; the demo uses + `POST /api/v1/workers/me/profile`. ## Verification commands @@ -389,7 +399,7 @@ cd backend && .venv/bin/python -m ruff check app/api/deps/auth.py app/api/routes cd backend && .venv/bin/python -m pytest tests/test_alembic.py tests/test_actors.py tests/test_auth.py -q cd backend && .venv/bin/python -m pytest tests/test_tasks.py -q cd backend && .venv/bin/docstr-coverage app/api app/modules/actors app/modules/tasks --config .docstr.yaml -! rg -n 'WorkerProfile|ReviewerProfile|/api/v1/demo/worker-profile' backend/scripts examples/terminal_benchmark backend/app/api/routes/demo.py backend/app/modules +! rg -n 'WorkerProfile|ReviewerProfile|/api/v1/demo/worker-profile' backend/scripts examples/terminal_benchmark backend/app/api/routes/demo.py backend/app/modules README.md demos/week1_api_demo_ui/src/App.tsx python3 scripts/check_stale_workstream_wording.py python3 scripts/check_markdown_links.py git diff --check diff --git a/README.md b/README.md index be686b9..a1d64bf 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,6 @@ 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 \ WORKSTREAM_PROJECT_SETUP_PIPELINE_AUTOSTART=false \ WORKSTREAM_CELERY_BROKER_URL=redis://localhost:6379/0 \ .venv/bin/alembic upgrade head @@ -229,7 +228,6 @@ 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 \ WORKSTREAM_PROJECT_SETUP_PIPELINE_AUTOSTART=false \ WORKSTREAM_CELERY_BROKER_URL=redis://localhost:6379/0 \ .venv/bin/python -m uvicorn app.main:create_app --factory --host 127.0.0.1 --port 8000 @@ -254,7 +252,9 @@ Open: 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. +The demo runs the Week 1 path from project guide to locked submission using +real API calls. Worker profile setup uses `POST /api/v1/workers/me/profile` +with a worker token; the old local demo worker-profile route is retired. ## Day-30 Success Standard diff --git a/backend/alembic/versions/0012_actor_identity_profile_registry.py b/backend/alembic/versions/0012_actor_identity_profile_registry.py new file mode 100644 index 0000000..0182871 --- /dev/null +++ b/backend/alembic/versions/0012_actor_identity_profile_registry.py @@ -0,0 +1,302 @@ +"""actor identity profile registry + +Revision ID: 0012_actor_identity_profiles +Revises: 0011_task_artifact_cleanup +Create Date: 2026-07-06 +""" + +from __future__ import annotations + +from uuid import uuid4 + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "0012_actor_identity_profiles" +down_revision = "0011_task_artifact_cleanup" +branch_labels = None +depends_on = None + + +actor_identities_table = sa.table( + "actor_identities", + sa.column("actor_id", sa.String), + sa.column("external_subject", sa.String), + sa.column("external_issuer", sa.String), + sa.column("display_name", sa.String), + sa.column("email", sa.String), + sa.column("last_seen_roles", sa.JSON), + sa.column("last_claim_snapshot", sa.JSON), + sa.column("auth_source", sa.String), + sa.column("is_dev_auth", sa.Boolean), + sa.column("last_seen_at", sa.DateTime), + sa.column("updated_at", sa.DateTime), +) + +actor_profiles_table = sa.table( + "actor_profiles", + sa.column("id", sa.String), + sa.column("actor_id", sa.String), + sa.column("profile_type", sa.String), + sa.column("status", sa.String), + sa.column("skill_tags", sa.JSON), + sa.column("scope_type", sa.String), + sa.column("scope_id", sa.String), + sa.column("profile_metadata", sa.JSON), + sa.column("updated_at", sa.DateTime), +) + + +def upgrade() -> None: + """Create actor registry tables and migrate old profile stores.""" + op.create_table( + "actor_identities", + sa.Column("actor_id", sa.String(length=100), nullable=False), + sa.Column("external_subject", sa.String(length=200), nullable=False), + sa.Column("external_issuer", sa.String(length=200), nullable=False), + sa.Column("display_name", sa.String(length=200), nullable=True), + sa.Column("email", sa.String(length=320), nullable=True), + sa.Column("last_seen_roles", sa.JSON(), nullable=False), + sa.Column("last_claim_snapshot", sa.JSON(), nullable=False), + sa.Column("auth_source", sa.String(length=50), nullable=False), + sa.Column("is_dev_auth", sa.Boolean(), nullable=False), + sa.Column("first_seen_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("actor_id", name=op.f("pk_actor_identities")), + sa.UniqueConstraint( + "external_issuer", + "external_subject", + name="uq_actor_identities_external_identity", + ), + ) + op.create_table( + "actor_profiles", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("actor_id", sa.String(length=100), nullable=False), + sa.Column("profile_type", sa.String(length=50), nullable=False), + sa.Column("status", sa.String(length=30), nullable=False), + sa.Column("skill_tags", sa.JSON(), nullable=False), + sa.Column("scope_type", sa.String(length=50), nullable=False), + sa.Column("scope_id", sa.String(length=100), nullable=False), + sa.Column("profile_metadata", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.CheckConstraint( + "profile_type in ('worker', 'reviewer', 'admin', 'project_manager', 'project_owner')", + name="ck_actor_profiles_profile_type", + ), + sa.CheckConstraint( + "status in ('observed', 'active', 'disabled')", + name="ck_actor_profiles_status", + ), + sa.ForeignKeyConstraint( + ["actor_id"], + ["actor_identities.actor_id"], + name=op.f("fk_actor_profiles_actor_id_actor_identities"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_actor_profiles")), + sa.UniqueConstraint( + "actor_id", + "profile_type", + "scope_type", + "scope_id", + name="uq_actor_profiles_actor_type_scope", + ), + ) + op.create_index(op.f("ix_actor_profiles_actor_id"), "actor_profiles", ["actor_id"], unique=False) + op.create_index(op.f("ix_actor_profiles_profile_type"), "actor_profiles", ["profile_type"], unique=False) + op.create_index(op.f("ix_actor_profiles_status"), "actor_profiles", ["status"], unique=False) + + connection = op.get_bind() + _backfill_legacy_profiles(connection, "worker_profiles", "worker") + _backfill_legacy_profiles(connection, "reviewer_profiles", "reviewer") + + op.drop_index(op.f("ix_reviewer_profiles_status"), table_name="reviewer_profiles") + op.drop_table("reviewer_profiles") + op.drop_index(op.f("ix_worker_profiles_status"), table_name="worker_profiles") + op.drop_table("worker_profiles") + + +def downgrade() -> None: + """Restore legacy profile tables from actor profiles, then drop registry tables.""" + op.create_table( + "worker_profiles", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("actor_id", sa.String(length=100), nullable=False), + sa.Column("external_subject", sa.String(length=200), nullable=False), + sa.Column("external_issuer", sa.String(length=200), nullable=False), + sa.Column("display_name", sa.String(length=200), nullable=True), + sa.Column("email", sa.String(length=320), nullable=True), + sa.Column("skill_tags", sa.JSON(), nullable=False), + sa.Column("status", sa.String(length=30), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_worker_profiles")), + sa.UniqueConstraint("actor_id", name=op.f("uq_worker_profiles_actor_id")), + ) + op.create_index(op.f("ix_worker_profiles_status"), "worker_profiles", ["status"], unique=False) + op.create_table( + "reviewer_profiles", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("actor_id", sa.String(length=100), nullable=False), + sa.Column("external_subject", sa.String(length=200), nullable=False), + sa.Column("external_issuer", sa.String(length=200), nullable=False), + sa.Column("display_name", sa.String(length=200), nullable=True), + sa.Column("email", sa.String(length=320), nullable=True), + sa.Column("skill_tags", sa.JSON(), nullable=False), + sa.Column("status", sa.String(length=30), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_reviewer_profiles")), + sa.UniqueConstraint("actor_id", name=op.f("uq_reviewer_profiles_actor_id")), + ) + op.create_index(op.f("ix_reviewer_profiles_status"), "reviewer_profiles", ["status"], unique=False) + + connection = op.get_bind() + _restore_legacy_profiles(connection, "worker_profiles", "worker") + _restore_legacy_profiles(connection, "reviewer_profiles", "reviewer") + + op.drop_index(op.f("ix_actor_profiles_status"), table_name="actor_profiles") + op.drop_index(op.f("ix_actor_profiles_profile_type"), table_name="actor_profiles") + op.drop_index(op.f("ix_actor_profiles_actor_id"), table_name="actor_profiles") + op.drop_table("actor_profiles") + op.drop_table("actor_identities") + + +def _backfill_legacy_profiles(connection, table_name: str, profile_type: str) -> None: + """Backfill one legacy profile table into actor identities and profiles.""" + rows = ( + connection.execute( + sa.text( + f""" + select + id, + actor_id, + external_subject, + external_issuer, + display_name, + email, + skill_tags, + status, + created_at, + updated_at + from {table_name} + """ + ) + ) + .mappings() + .all() + ) + for row in rows: + roles = [profile_type] + connection.execute( + postgresql.insert(actor_identities_table) + .values( + actor_id=row["actor_id"], + external_subject=row["external_subject"], + external_issuer=row["external_issuer"], + display_name=row["display_name"], + email=row["email"], + last_seen_roles=roles, + last_claim_snapshot={"legacy_profile_backfill": table_name}, + auth_source="legacy_profile_backfill", + is_dev_auth=False, + ) + .on_conflict_do_update( + index_elements=["actor_id"], + set_={ + "external_subject": row["external_subject"], + "external_issuer": row["external_issuer"], + "display_name": row["display_name"], + "email": row["email"], + "last_seen_roles": roles, + "last_claim_snapshot": {"legacy_profile_backfill": table_name}, + "auth_source": "legacy_profile_backfill", + "is_dev_auth": False, + "last_seen_at": sa.func.now(), + "updated_at": sa.func.now(), + }, + ) + ) + connection.execute( + postgresql.insert(actor_profiles_table) + .values( + id=row["id"], + actor_id=row["actor_id"], + profile_type=profile_type, + status=row["status"], + skill_tags=row["skill_tags"] or [], + scope_type="global", + scope_id="global", + profile_metadata={"legacy_profile_backfill": table_name}, + ) + .on_conflict_do_update( + index_elements=["actor_id", "profile_type", "scope_type", "scope_id"], + set_={ + "status": row["status"], + "skill_tags": row["skill_tags"] or [], + "profile_metadata": {"legacy_profile_backfill": table_name}, + "updated_at": sa.func.now(), + }, + ) + ) + + +def _restore_legacy_profiles(connection, table_name: str, profile_type: str) -> None: + """Restore a legacy profile table from actor profile rows during downgrade.""" + legacy_profiles_table = _legacy_profile_table(table_name) + rows = ( + connection.execute( + sa.text( + """ + select + p.id, + p.actor_id, + i.external_subject, + i.external_issuer, + i.display_name, + i.email, + p.skill_tags, + p.status + from actor_profiles p + join actor_identities i on i.actor_id = p.actor_id + where p.profile_type = :profile_type + and p.scope_type = 'global' + and p.scope_id = 'global' + """ + ), + {"profile_type": profile_type}, + ) + .mappings() + .all() + ) + for row in rows: + connection.execute( + sa.insert(legacy_profiles_table).values( + id=row["id"] or str(uuid4()), + actor_id=row["actor_id"], + external_subject=row["external_subject"], + external_issuer=row["external_issuer"], + display_name=row["display_name"], + email=row["email"], + skill_tags=row["skill_tags"] or [], + status=row["status"], + ) + ) + + +def _legacy_profile_table(table_name: str) -> sa.TableClause: + """Return a typed table clause for restored legacy profile rows.""" + return sa.table( + table_name, + sa.column("id", sa.String), + sa.column("actor_id", sa.String), + sa.column("external_subject", sa.String), + sa.column("external_issuer", sa.String), + sa.column("display_name", sa.String), + sa.column("email", sa.String), + sa.column("skill_tags", sa.JSON), + sa.column("status", sa.String), + ) diff --git a/backend/app/api/deps/auth.py b/backend/app/api/deps/auth.py index a7a510e..a0a06dd 100644 --- a/backend/app/api/deps/auth.py +++ b/backend/app/api/deps/auth.py @@ -6,9 +6,12 @@ from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.ext.asyncio import AsyncSession from app.core.auth import get_auth_verifier +from app.db.session import get_db_session from app.interfaces.auth import AuthVerificationError, AuthVerifier +from app.modules.actors.service import ActorService from app.schemas.auth import ActorContext bearer_scheme = HTTPBearer(auto_error=False) @@ -53,3 +56,21 @@ async def get_current_actor( return await verifier.verify(credentials.credentials) except AuthVerificationError as exc: raise unauthorized("Invalid bearer token") from exc + + +async def get_registered_actor( + actor: Annotated[ActorContext, Depends(get_current_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> ActorContext: + """Resolve the current actor and refresh local registry metadata. + + Args: + actor: Verified actor returned by the pure auth boundary. + session: Database session for registry persistence. + + Returns: + The same verified actor context. Route authorization must still use + this token-derived context, not persisted profile rows. + """ + await ActorService(session).register_actor(actor) + return actor diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index f05815c..d6f1899 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends -from app.api.deps.auth import get_current_actor +from app.api.deps.auth import get_registered_actor from app.schemas.auth import ActorContext, ActorResponse router = APIRouter(prefix="/auth", tags=["auth"]) @@ -14,7 +14,7 @@ @router.get("/me", response_model=ActorResponse) async def read_current_actor( - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], ) -> ActorResponse: """Return the actor context derived from the current bearer token. diff --git a/backend/app/api/routes/demo.py b/backend/app/api/routes/demo.py index 6666720..30ed5ef 100644 --- a/backend/app/api/routes/demo.py +++ b/backend/app/api/routes/demo.py @@ -1,45 +1,15 @@ -"""Local demo routes for exercising Week 1 flows from the frontend.""" +"""Local demo route helpers for exercising local-only flows.""" 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 +from fastapi import APIRouter, HTTPException +from app.core.config import Settings 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. @@ -53,50 +23,3 @@ def ensure_demo_routes_enabled(settings: Settings) -> None: 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/db/models.py b/backend/app/db/models.py index ace4767..d674782 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1,5 +1,6 @@ """Import domain models so Alembic can discover metadata.""" +from app.modules.actors.models import ActorIdentity, ActorProfile # noqa: F401 from app.modules.checkers.models import CheckerResult, CheckerRun # noqa: F401 from app.modules.projects.models import ( # noqa: F401 EffectiveProjectSubmissionArtifactPolicy, @@ -18,9 +19,7 @@ from app.modules.tasks.models import ( # noqa: F401 AuditEvent, EvidenceItem, - ReviewerProfile, Submission, TaskAssignment, - WorkerProfile, WorkstreamTask, ) diff --git a/backend/app/modules/actors/__init__.py b/backend/app/modules/actors/__init__.py new file mode 100644 index 0000000..dd1e6d9 --- /dev/null +++ b/backend/app/modules/actors/__init__.py @@ -0,0 +1,2 @@ +"""Actor identity and profile registry module.""" + diff --git a/backend/app/modules/actors/models.py b/backend/app/modules/actors/models.py new file mode 100644 index 0000000..a3b2962 --- /dev/null +++ b/backend/app/modules/actors/models.py @@ -0,0 +1,96 @@ +"""SQLAlchemy models for verified actor identity and profile metadata.""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, JSON, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.db.base import Base + +ACTOR_PROFILE_TYPES = ("worker", "reviewer", "admin", "project_manager", "project_owner") +ACTOR_PROFILE_STATUSES = ("observed", "active", "disabled") +GLOBAL_PROFILE_SCOPE_TYPE = "global" +GLOBAL_PROFILE_SCOPE_ID = "global" + + +class ActorIdentity(Base): + """Local registry row for a verified external Flow actor.""" + + __tablename__ = "actor_identities" + __table_args__ = ( + UniqueConstraint( + "external_issuer", + "external_subject", + name="uq_actor_identities_external_identity", + ), + ) + + actor_id: Mapped[str] = mapped_column(String(100), primary_key=True) + external_subject: Mapped[str] = mapped_column(String(200), nullable=False) + external_issuer: Mapped[str] = mapped_column(String(200), nullable=False) + display_name: Mapped[str | None] = mapped_column(String(200)) + email: Mapped[str | None] = mapped_column(String(320)) + last_seen_roles: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + last_claim_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + auth_source: Mapped[str] = mapped_column(String(50), nullable=False) + is_dev_auth: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + profiles: Mapped[list[ActorProfile]] = relationship( + back_populates="identity", + cascade="all, delete-orphan", + ) + + +class ActorProfile(Base): + """Shared workflow metadata and eligibility profile for an actor.""" + + __tablename__ = "actor_profiles" + __table_args__ = ( + CheckConstraint( + "profile_type in ('worker', 'reviewer', 'admin', 'project_manager', 'project_owner')", + name="ck_actor_profiles_profile_type", + ), + CheckConstraint( + "status in ('observed', 'active', 'disabled')", + name="ck_actor_profiles_status", + ), + UniqueConstraint( + "actor_id", + "profile_type", + "scope_type", + "scope_id", + name="uq_actor_profiles_actor_type_scope", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + actor_id: Mapped[str] = mapped_column( + ForeignKey("actor_identities.actor_id"), + nullable=False, + index=True, + ) + profile_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + status: Mapped[str] = mapped_column(String(30), nullable=False, default="observed", index=True) + skill_tags: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + scope_type: Mapped[str] = mapped_column(String(50), nullable=False, default=GLOBAL_PROFILE_SCOPE_TYPE) + scope_id: Mapped[str] = mapped_column(String(100), nullable=False, default=GLOBAL_PROFILE_SCOPE_ID) + profile_metadata: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + identity: Mapped[ActorIdentity] = relationship(back_populates="profiles") + diff --git a/backend/app/modules/actors/repository.py b/backend/app/modules/actors/repository.py new file mode 100644 index 0000000..cf0496f --- /dev/null +++ b/backend/app/modules/actors/repository.py @@ -0,0 +1,154 @@ +"""Database access methods for actor identities and profiles.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from sqlalchemy import func, select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.actors.models import ActorIdentity, ActorProfile + + +class ActorRepository: + """Wraps SQLAlchemy persistence for actor registry operations.""" + + def __init__(self, session: AsyncSession) -> None: + """Create a repository bound to one database session. + + Args: + session: Async SQLAlchemy session for the current unit of work. + """ + self._session = session + + async def get_identity(self, actor_id: str) -> ActorIdentity | None: + """Load an actor identity by stable Workstream actor id. + + Args: + actor_id: Stable actor id. + + Returns: + Actor identity when present; otherwise ``None``. + """ + return await self._session.get(ActorIdentity, actor_id) + + async def upsert_identity(self, identity: ActorIdentity) -> ActorIdentity: + """Create or refresh an actor identity from trusted token claims. + + Args: + identity: Actor identity carrying the latest trusted claim snapshot. + + Returns: + Persisted actor identity. + """ + await self._session.execute( + insert(ActorIdentity) + .values( + actor_id=identity.actor_id, + external_subject=identity.external_subject, + external_issuer=identity.external_issuer, + display_name=identity.display_name, + email=identity.email, + last_seen_roles=identity.last_seen_roles, + last_claim_snapshot=identity.last_claim_snapshot, + auth_source=identity.auth_source, + is_dev_auth=identity.is_dev_auth, + ) + .on_conflict_do_update( + index_elements=[ActorIdentity.actor_id], + set_={ + "external_subject": identity.external_subject, + "external_issuer": identity.external_issuer, + "display_name": identity.display_name, + "email": identity.email, + "last_seen_roles": identity.last_seen_roles, + "last_claim_snapshot": identity.last_claim_snapshot, + "auth_source": identity.auth_source, + "is_dev_auth": identity.is_dev_auth, + "last_seen_at": func.now(), + "updated_at": func.now(), + }, + ) + ) + await self._session.flush() + persisted = await self.get_identity(identity.actor_id) + if persisted is None: + raise RuntimeError("actor identity upsert did not return a persisted row") + return persisted + + async def get_profile( + self, + actor_id: str, + profile_type: str, + scope_type: str, + scope_id: str, + ) -> ActorProfile | None: + """Load one actor profile by actor, type, and scope. + + Args: + actor_id: Stable actor id. + profile_type: Profile type such as ``worker`` or ``project_manager``. + scope_type: Scope namespace, usually ``global``. + scope_id: Scope identifier, usually ``global``. + + Returns: + Actor profile when present; otherwise ``None``. + """ + result = await self._session.execute( + select(ActorProfile) + .where( + ActorProfile.actor_id == actor_id, + ActorProfile.profile_type == profile_type, + ActorProfile.scope_type == scope_type, + ActorProfile.scope_id == scope_id, + ) + .execution_options(populate_existing=True) + ) + return result.scalar_one_or_none() + + async def list_profiles(self, actor_id: str) -> Sequence[ActorProfile]: + """List profiles for one actor. + + Args: + actor_id: Stable actor id. + + Returns: + Actor profiles ordered by type and scope. + """ + result = await self._session.execute( + select(ActorProfile) + .where(ActorProfile.actor_id == actor_id) + .order_by(ActorProfile.profile_type.asc(), ActorProfile.scope_type.asc()) + ) + return result.scalars().all() + + async def insert_profile_if_absent(self, profile: ActorProfile) -> None: + """Insert a profile without overwriting existing scoped profile state. + + Args: + profile: Actor profile to insert when absent. + """ + await self._session.execute( + insert(ActorProfile) + .values( + id=profile.id, + actor_id=profile.actor_id, + profile_type=profile.profile_type, + status=profile.status, + skill_tags=profile.skill_tags, + scope_type=profile.scope_type, + scope_id=profile.scope_id, + profile_metadata=profile.profile_metadata, + ) + .on_conflict_do_nothing( + index_elements=[ + ActorProfile.actor_id, + ActorProfile.profile_type, + ActorProfile.scope_type, + ActorProfile.scope_id, + ] + ) + ) + await self._session.flush() + diff --git a/backend/app/modules/actors/schemas.py b/backend/app/modules/actors/schemas.py new file mode 100644 index 0000000..8954842 --- /dev/null +++ b/backend/app/modules/actors/schemas.py @@ -0,0 +1,69 @@ +"""Pydantic schemas for actor profile APIs.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +def normalize_skill_tags(value: list[str]) -> list[str]: + """Normalize actor skill tags to stable lowercase tokens. + + Args: + value: Client or service supplied skill tags. + + Returns: + Deduplicated normalized skill tags. + + Raises: + ValueError: If any tag is empty or too long. + """ + normalized_tags: list[str] = [] + seen_tags: set[str] = set() + for raw_tag in value: + tag = raw_tag.strip().lower() + if not tag: + raise ValueError("skill_tags cannot include empty values") + if len(tag) > 64: + raise ValueError("skill_tags values must be 64 characters or fewer") + if tag not in seen_tags: + normalized_tags.append(tag) + seen_tags.add(tag) + return normalized_tags + + +class ActorProfileActivationRequest(BaseModel): + """Request schema for explicitly activating the current actor's profile.""" + + model_config = ConfigDict(extra="forbid") + + skill_tags: list[str] = Field(default_factory=list, max_length=100) + + @field_validator("skill_tags") + @classmethod + def validate_skill_tags(cls, value: list[str]) -> list[str]: + """Normalize profile skill tags before persistence.""" + return normalize_skill_tags(value) + + +class ActorProfileResponse(BaseModel): + """Response schema for a profile joined to token-derived identity metadata.""" + + model_config = ConfigDict(from_attributes=True) + + id: str + actor_id: str + profile_type: str + status: str + skill_tags: list[str] + scope_type: str + scope_id: str + profile_metadata: dict + external_subject: str + external_issuer: str + display_name: str | None + email: str | None + created_at: datetime + updated_at: datetime + diff --git a/backend/app/modules/actors/service.py b/backend/app/modules/actors/service.py new file mode 100644 index 0000000..748011e --- /dev/null +++ b/backend/app/modules/actors/service.py @@ -0,0 +1,389 @@ +"""Service layer for actor identity registration and profile eligibility.""" + +from __future__ import annotations + +from collections.abc import Iterable +from uuid import uuid4 + +from sqlalchemy import func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.permissions import require_any_role +from app.modules.actors.models import ( + ACTOR_PROFILE_TYPES, + GLOBAL_PROFILE_SCOPE_ID, + GLOBAL_PROFILE_SCOPE_TYPE, + ActorIdentity, + ActorProfile, +) +from app.modules.actors.repository import ActorRepository +from app.modules.actors.schemas import ActorProfileActivationRequest, ActorProfileResponse +from app.modules.tasks.models import AuditEvent +from app.modules.tasks.repository import TaskRepository +from app.schemas.auth import ActorContext + +TOKEN_OBSERVED_PROFILE_TYPES = {"worker", "reviewer", "admin", "project_manager"} + + +class ActorRegistryError(Exception): + """Base error for actor registry failures.""" + + status_code = 400 + + +class ActorProfileDisabled(ActorRegistryError): + """Raised when an explicit profile workflow tries to use a disabled profile.""" + + status_code = 403 + + +class ActorService: + """Coordinates actor identity and profile registry rules.""" + + def __init__(self, session: AsyncSession) -> None: + """Create a service instance bound to one database session. + + Args: + session: Async SQLAlchemy session for the current request. + """ + self._session = session + self._repo = ActorRepository(session) + self._audit_repo = TaskRepository(session) + + async def register_actor(self, actor: ActorContext) -> ActorIdentity: + """Create or refresh identity and observed profiles for a verified actor. + + Args: + actor: Trusted actor context returned by Flow token verification. + + Returns: + Persisted actor identity. + """ + identity = await self._repo.upsert_identity(self._identity_from_actor(actor)) + for profile_type in self._observed_profile_types(actor.roles): + await self.ensure_observed_profile( + actor, + profile_type=profile_type, + scope_type=GLOBAL_PROFILE_SCOPE_TYPE, + scope_id=GLOBAL_PROFILE_SCOPE_ID, + profile_metadata={"source": "verified_token_role"}, + identity_already_refreshed=True, + ) + for relationship in self._trusted_relationship_profiles(actor): + await self.ensure_observed_profile( + actor, + profile_type=relationship["profile_type"], + scope_type=relationship["scope_type"], + scope_id=relationship["scope_id"], + profile_metadata=relationship["profile_metadata"], + identity_already_refreshed=True, + ) + await self._session.commit() + await self._session.refresh(identity) + return identity + + async def ensure_observed_profile( + self, + actor: ActorContext, + *, + profile_type: str, + scope_type: str, + scope_id: str, + profile_metadata: dict | None = None, + identity_already_refreshed: bool = False, + ) -> ActorProfile: + """Create or refresh an observed profile without changing eligibility. + + Args: + actor: Trusted actor context. + profile_type: Profile type to observe. + scope_type: Profile scope namespace. + scope_id: Profile scope identifier. + profile_metadata: Non-authoritative metadata to store. + identity_already_refreshed: Whether the caller already refreshed + the trusted identity in the current request. + + Returns: + Persisted actor profile. + """ + self._validate_profile_type(profile_type) + if not identity_already_refreshed: + await self._repo.upsert_identity(self._identity_from_actor(actor)) + existing = await self._repo.get_profile(actor.actor_id, profile_type, scope_type, scope_id) + if existing is None: + await self._repo.insert_profile_if_absent( + ActorProfile( + id=str(uuid4()), + actor_id=actor.actor_id, + profile_type=profile_type, + status="observed", + skill_tags=[], + scope_type=scope_type, + scope_id=scope_id, + profile_metadata=profile_metadata or {}, + ) + ) + profile = await self._repo.get_profile(actor.actor_id, profile_type, scope_type, scope_id) + if profile is None: + raise RuntimeError("actor profile insert did not return a persisted row") + await self._write_profile_audit( + actor, + profile, + event_type="actor_profile_observed", + from_status=None, + to_status=profile.status, + event_payload={"profile_metadata": profile.profile_metadata}, + ) + return profile + + if existing.status != "observed": + existing.updated_at = func.now() + return existing + + next_metadata = profile_metadata or existing.profile_metadata + existing.updated_at = func.now() + if existing.profile_metadata != next_metadata: + previous_metadata = dict(existing.profile_metadata or {}) + existing.profile_metadata = next_metadata + await self._write_profile_audit( + actor, + existing, + event_type="actor_profile_observation_refreshed", + from_status=existing.status, + to_status=existing.status, + event_payload={ + "previous_profile_metadata": previous_metadata, + "profile_metadata": existing.profile_metadata, + }, + ) + return existing + + async def activate_worker_profile( + self, + actor: ActorContext, + payload: ActorProfileActivationRequest, + ) -> ActorProfileResponse: + """Activate or refresh the current actor's worker profile. + + Args: + actor: Trusted actor context. + payload: Worker-owned profile fields. + + Returns: + Profile response joined with trusted identity metadata. + + Raises: + ActorProfileDisabled: If the worker profile has been disabled. + """ + require_any_role(actor, {"worker"}) + identity = await self._repo.upsert_identity(self._identity_from_actor(actor)) + profile = await self._repo.get_profile( + actor.actor_id, + "worker", + GLOBAL_PROFILE_SCOPE_TYPE, + GLOBAL_PROFILE_SCOPE_ID, + ) + if profile is None: + await self._repo.insert_profile_if_absent( + ActorProfile( + id=str(uuid4()), + actor_id=actor.actor_id, + profile_type="worker", + status="active", + skill_tags=payload.skill_tags, + scope_type=GLOBAL_PROFILE_SCOPE_TYPE, + scope_id=GLOBAL_PROFILE_SCOPE_ID, + profile_metadata={"source": "worker_profile_api"}, + ) + ) + profile = await self._repo.get_profile( + actor.actor_id, + "worker", + GLOBAL_PROFILE_SCOPE_TYPE, + GLOBAL_PROFILE_SCOPE_ID, + ) + if profile is None: + raise RuntimeError("worker actor profile insert did not return a persisted row") + await self._write_profile_audit( + actor, + profile, + event_type="actor_profile_activated", + from_status=None, + to_status="active", + event_payload={"skill_tags": profile.skill_tags}, + ) + else: + if profile.status == "disabled": + raise ActorProfileDisabled("worker profile is disabled") + from_status = profile.status + previous_tags = list(profile.skill_tags) + profile.status = "active" + profile.skill_tags = payload.skill_tags + profile.profile_metadata = { + **(profile.profile_metadata or {}), + "source": "worker_profile_api", + } + if from_status != profile.status or previous_tags != profile.skill_tags: + await self._write_profile_audit( + actor, + profile, + event_type="actor_profile_activated", + from_status=from_status, + to_status=profile.status, + event_payload={ + "previous_skill_tags": previous_tags, + "skill_tags": profile.skill_tags, + }, + ) + await self._session.commit() + await self._session.refresh(identity) + await self._session.refresh(profile) + return self._profile_response(profile, identity) + + async def get_active_profile(self, actor_id: str, profile_type: str) -> ActorProfile | None: + """Load an active global profile when workflow eligibility requires it. + + Args: + actor_id: Stable actor id. + profile_type: Profile type required by the workflow. + + Returns: + Active profile when present; otherwise ``None``. + """ + profile = await self._repo.get_profile( + actor_id, + profile_type, + GLOBAL_PROFILE_SCOPE_TYPE, + GLOBAL_PROFILE_SCOPE_ID, + ) + if profile is None or profile.status != "active": + return None + return profile + + def _identity_from_actor(self, actor: ActorContext) -> ActorIdentity: + """Build an identity model from trusted actor claims.""" + return ActorIdentity( + actor_id=actor.actor_id, + external_subject=actor.external_subject, + external_issuer=actor.external_issuer, + display_name=actor.display_name, + email=actor.email, + last_seen_roles=list(actor.roles), + last_claim_snapshot=actor.claim_snapshot, + auth_source=actor.auth_source, + is_dev_auth=actor.is_dev_auth, + ) + + def _profile_response( + self, + profile: ActorProfile, + identity: ActorIdentity, + ) -> ActorProfileResponse: + """Build a profile response from profile and identity rows.""" + return ActorProfileResponse( + id=profile.id, + actor_id=profile.actor_id, + profile_type=profile.profile_type, + status=profile.status, + skill_tags=profile.skill_tags, + scope_type=profile.scope_type, + scope_id=profile.scope_id, + profile_metadata=profile.profile_metadata, + external_subject=identity.external_subject, + external_issuer=identity.external_issuer, + display_name=identity.display_name, + email=identity.email, + created_at=profile.created_at, + updated_at=profile.updated_at, + ) + + def _observed_profile_types(self, roles: Iterable[str]) -> list[str]: + """Return profile types that may be observed from verified token roles.""" + profile_types: list[str] = [] + for role in roles: + if role in TOKEN_OBSERVED_PROFILE_TYPES and role not in profile_types: + profile_types.append(role) + return profile_types + + def _trusted_relationship_profiles(self, actor: ActorContext) -> list[dict]: + """Extract trusted scoped relationship profiles from actor claims. + + Returns: + Sanitized project-owner relationship profile claims. + """ + raw_profiles = actor.claim_snapshot.get("workstream_relationship_profiles", []) + if not isinstance(raw_profiles, list): + return [] + profiles: list[dict] = [] + for raw_profile in raw_profiles: + if not isinstance(raw_profile, dict): + continue + if raw_profile.get("profile_type") != "project_owner": + continue + scope_type = raw_profile.get("scope_type") + scope_id = raw_profile.get("scope_id") + if not isinstance(scope_type, str) or not isinstance(scope_id, str): + continue + scope_type = scope_type.strip() + scope_id = scope_id.strip() + if not scope_type or not scope_id: + continue + profiles.append( + { + "profile_type": "project_owner", + "scope_type": scope_type, + "scope_id": scope_id, + "profile_metadata": { + "source": "trusted_relationship_claim", + **( + raw_profile.get("profile_metadata") + if isinstance(raw_profile.get("profile_metadata"), dict) + else {} + ), + }, + } + ) + return profiles + + def _validate_profile_type(self, profile_type: str) -> None: + """Validate profile type before persistence.""" + if profile_type not in ACTOR_PROFILE_TYPES: + raise ValueError(f"unsupported actor profile type: {profile_type}") + + async def _write_profile_audit( + self, + actor: ActorContext, + profile: ActorProfile, + *, + event_type: str, + from_status: str | None, + to_status: str | None, + event_payload: dict, + ) -> None: + """Write audit evidence for profile eligibility changes.""" + audit = actor.audit_context() + await self._audit_repo.add_audit_event( + AuditEvent( + id=str(uuid4()), + entity_type="actor_profile", + entity_id=profile.id, + event_type=event_type, + from_status=from_status, + to_status=to_status, + actor_id=audit.actor_id, + external_subject=audit.external_subject, + external_issuer=audit.external_issuer, + actor_roles=list(audit.actor_roles), + claim_snapshot=audit.claim_snapshot, + auth_source=audit.auth_source, + is_dev_auth=audit.is_dev_auth, + reason=None, + event_payload={ + "actor_id": profile.actor_id, + "profile_type": profile.profile_type, + "scope_type": profile.scope_type, + "scope_id": profile.scope_id, + **event_payload, + }, + ) + ) diff --git a/backend/app/modules/tasks/models.py b/backend/app/modules/tasks/models.py index 4faa4d2..19d88d6 100644 --- a/backend/app/modules/tasks/models.py +++ b/backend/app/modules/tasks/models.py @@ -1,4 +1,4 @@ -"""SQLAlchemy models for task queue, assignment, profiles, and audit events.""" +"""SQLAlchemy models for task queue, assignment, submissions, and audit events.""" from __future__ import annotations @@ -26,48 +26,6 @@ from app.db.base import Base -class WorkerProfile(Base): - """Worker profile derived from a trusted external Flow actor.""" - - __tablename__ = "worker_profiles" - - id: Mapped[str] = mapped_column(String(36), primary_key=True) - actor_id: Mapped[str] = mapped_column(String(100), nullable=False, unique=True) - external_subject: Mapped[str] = mapped_column(String(200), nullable=False) - external_issuer: Mapped[str] = mapped_column(String(200), nullable=False) - display_name: Mapped[str | None] = mapped_column(String(200)) - email: Mapped[str | None] = mapped_column(String(320)) - skill_tags: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) - status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - ) - - -class ReviewerProfile(Base): - """Reviewer profile derived from a trusted external Flow actor.""" - - __tablename__ = "reviewer_profiles" - - id: Mapped[str] = mapped_column(String(36), primary_key=True) - actor_id: Mapped[str] = mapped_column(String(100), nullable=False, unique=True) - external_subject: Mapped[str] = mapped_column(String(200), nullable=False) - external_issuer: Mapped[str] = mapped_column(String(200), nullable=False) - display_name: Mapped[str | None] = mapped_column(String(200)) - email: Mapped[str | None] = mapped_column(String(320)) - skill_tags: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) - status: Mapped[str] = mapped_column(String(30), nullable=False, default="active", index=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - ) - - class WorkstreamTask(Base): """Task queue record with server-stamped guide and policy context.""" diff --git a/backend/app/modules/tasks/repository.py b/backend/app/modules/tasks/repository.py index 173ced7..e06316e 100644 --- a/backend/app/modules/tasks/repository.py +++ b/backend/app/modules/tasks/repository.py @@ -1,22 +1,19 @@ -"""Database access methods for tasks, assignments, profiles, and audit events.""" +"""Database access methods for tasks, assignments, submissions, and audit events.""" from __future__ import annotations from collections.abc import Sequence from datetime import datetime -from sqlalchemy import func, select -from sqlalchemy.dialects.postgresql import insert +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.modules.tasks.models import ( AuditEvent, EvidenceItem, - ReviewerProfile, Submission, TaskAssignment, - WorkerProfile, WorkstreamTask, ) @@ -165,118 +162,6 @@ async def lock_submission_evidence(self, submission_id: str, locked_at: datetime for evidence in result.scalars(): evidence.locked_at = locked_at - async def get_worker_profile(self, actor_id: str) -> WorkerProfile | None: - """Load a worker profile by actor id. - - Args: - actor_id: Stable Workstream actor id. - - Returns: - Worker profile when found; otherwise ``None``. - """ - result = await self._session.execute( - select(WorkerProfile) - .where(WorkerProfile.actor_id == actor_id) - .execution_options(populate_existing=True) - ) - return result.scalar_one_or_none() - - async def upsert_worker_profile(self, profile: WorkerProfile) -> WorkerProfile: - """Create or update a worker profile from trusted actor claims. - - Args: - profile: Worker profile carrying latest actor metadata. - - Returns: - Persisted worker profile. - """ - await self._session.execute( - insert(WorkerProfile) - .values( - 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, - ) - .on_conflict_do_update( - index_elements=[WorkerProfile.actor_id], - set_={ - "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, - "updated_at": func.now(), - }, - ) - ) - await self._session.flush() - persisted = await self.get_worker_profile(profile.actor_id) - if persisted is None: - raise RuntimeError("worker profile upsert did not return a persisted row") - return persisted - - async def get_reviewer_profile(self, actor_id: str) -> ReviewerProfile | None: - """Load a reviewer profile by actor id. - - Args: - actor_id: Stable Workstream actor id. - - Returns: - Reviewer profile when found; otherwise ``None``. - """ - result = await self._session.execute( - select(ReviewerProfile) - .where(ReviewerProfile.actor_id == actor_id) - .execution_options(populate_existing=True) - ) - return result.scalar_one_or_none() - - async def upsert_reviewer_profile(self, profile: ReviewerProfile) -> ReviewerProfile: - """Create or update a reviewer profile from trusted actor claims. - - Args: - profile: Reviewer profile carrying latest actor metadata. - - Returns: - Persisted reviewer profile. - """ - await self._session.execute( - insert(ReviewerProfile) - .values( - 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, - ) - .on_conflict_do_update( - index_elements=[ReviewerProfile.actor_id], - set_={ - "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, - "updated_at": func.now(), - }, - ) - ) - await self._session.flush() - persisted = await self.get_reviewer_profile(profile.actor_id) - if persisted is None: - raise RuntimeError("reviewer profile upsert did not return a persisted row") - return persisted - async def add_audit_event(self, event: AuditEvent) -> AuditEvent: """Persist an audit event. diff --git a/backend/app/modules/tasks/router.py b/backend/app/modules/tasks/router.py index 4808abd..c022d48 100644 --- a/backend/app/modules/tasks/router.py +++ b/backend/app/modules/tasks/router.py @@ -8,9 +8,11 @@ from fastapi.responses import JSONResponse from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps.auth import get_current_actor +from app.api.deps.auth import get_current_actor, get_registered_actor from app.core.permissions import PermissionDenied from app.db.session import get_db_session +from app.modules.actors.schemas import ActorProfileActivationRequest, ActorProfileResponse +from app.modules.actors.service import ActorRegistryError, ActorService from app.modules.tasks.schemas import ( AuditEventResponse, SubmissionCreate, @@ -19,8 +21,6 @@ TaskResponse, TaskTransitionRequest, TaskWithAssignmentResponse, - WorkerProfileResponse, - WorkerProfileUpsertRequest, ) from app.modules.tasks.service import TaskService, TaskServiceError from app.schemas.auth import ActorContext @@ -81,22 +81,27 @@ def permission_http_error(exc: PermissionDenied) -> HTTPException: return HTTPException(status_code=403, detail=str(exc)) +def actor_registry_http_error(exc: ActorRegistryError) -> HTTPException: + """Convert an actor-registry failure into an HTTP error.""" + return HTTPException(status_code=exc.status_code, detail=str(exc)) + + @router.post( "/workers/me/profile", - response_model=WorkerProfileResponse, + response_model=ActorProfileResponse, ) async def ensure_worker_profile( - payload: WorkerProfileUpsertRequest, - actor: Annotated[ActorContext, Depends(get_current_actor)], + payload: ActorProfileActivationRequest, + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], -) -> WorkerProfileResponse: +) -> ActorProfileResponse: """Create or refresh the current worker profile from trusted Flow identity.""" try: - return await TaskService(session).ensure_worker_profile(actor, payload) + return await ActorService(session).activate_worker_profile(actor, payload) except PermissionDenied as exc: raise permission_http_error(exc) from exc - except TaskServiceError as exc: - raise task_http_error(exc) from exc + except ActorRegistryError as exc: + raise actor_registry_http_error(exc) from exc @router.post( @@ -190,7 +195,7 @@ async def release_task( ) async def claim_task( task_id: str, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], payload: TaskTransitionRequest | None = None, ) -> TaskWithAssignmentResponse: diff --git a/backend/app/modules/tasks/schemas.py b/backend/app/modules/tasks/schemas.py index c45ecf7..e08f1c0 100644 --- a/backend/app/modules/tasks/schemas.py +++ b/backend/app/modules/tasks/schemas.py @@ -96,48 +96,6 @@ class TaskTransitionRequest(BaseModel): reason: str | None = None -class WorkerProfileUpsertRequest(BaseModel): - """Request schema for creating or refreshing the current worker profile.""" - - model_config = ConfigDict(extra="forbid") - - skill_tags: list[str] = Field(default_factory=list, max_length=100) - - @field_validator("skill_tags") - @classmethod - def normalize_skill_tags(cls, value: list[str]) -> list[str]: - """Normalize worker skill tags before they enter profile metadata.""" - normalized_tags: list[str] = [] - seen_tags: set[str] = set() - for raw_tag in value: - tag = raw_tag.strip().lower() - if not tag: - raise ValueError("skill_tags cannot include empty values") - if len(tag) > 64: - raise ValueError("skill_tags values must be 64 characters or fewer") - if tag not in seen_tags: - normalized_tags.append(tag) - seen_tags.add(tag) - return normalized_tags - - -class WorkerProfileResponse(BaseModel): - """Response schema for a worker profile derived from Flow identity.""" - - model_config = ConfigDict(from_attributes=True) - - id: str - actor_id: str - external_subject: str - external_issuer: str - display_name: str | None - email: str | None - skill_tags: list[str] - status: str - created_at: datetime - updated_at: datetime - - class EvidenceItemCreate(BaseModel): """Request schema for one evidence item in a submission packet.""" diff --git a/backend/app/modules/tasks/service.py b/backend/app/modules/tasks/service.py index 2d81ead..c048406 100644 --- a/backend/app/modules/tasks/service.py +++ b/backend/app/modules/tasks/service.py @@ -9,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.permissions import require_any_role +from app.modules.actors.models import ActorProfile +from app.modules.actors.service import ActorService from app.modules.projects.models import ( EffectiveProjectSubmissionArtifactPolicy, GuideSourceSnapshot, @@ -37,10 +39,8 @@ from app.modules.tasks.models import ( AuditEvent, EvidenceItem, - ReviewerProfile, Submission, TaskAssignment, - WorkerProfile, WorkstreamTask, ) from app.modules.tasks.repository import TaskRepository @@ -52,8 +52,6 @@ TaskCreate, TaskResponse, TaskWithAssignmentResponse, - WorkerProfileResponse, - WorkerProfileUpsertRequest, ) from app.schemas.auth import ActorContext @@ -120,8 +118,8 @@ class TaskAssignmentConflict(TaskServiceError): status_code = 409 -class WorkerProfileRequired(TaskServiceError): - """Raised when a worker tries to claim without an active worker profile.""" +class WorkerEligibilityRequired(TaskServiceError): + """Raised when a worker tries to claim without active worker profile eligibility.""" status_code = 403 @@ -176,6 +174,7 @@ def __init__(self, session: AsyncSession) -> None: self._session = session self._repo = TaskRepository(session) self._project_repo = ProjectRepository(session) + self._actor_service = ActorService(session) async def create_task( self, @@ -1051,82 +1050,23 @@ async def _validate_locked_post_submit_policy_context(self, task: WorkstreamTask async def _require_active_worker_profile( self, actor: ActorContext, - ) -> WorkerProfile: - """Require an active worker profile before claim. + ) -> ActorProfile: + """Require active worker actor profile eligibility before claim. Args: actor: Verified Flow actor context. Returns: - Persisted active worker profile. + Persisted active worker actor profile. Raises: - WorkerProfileRequired: If the actor has no active worker profile. + WorkerEligibilityRequired: If the actor has no active worker profile. """ - profile = await self._repo.get_worker_profile(actor.actor_id) - if profile is None or profile.status != "active": - raise WorkerProfileRequired("active worker profile is required to claim task") + profile = await self._actor_service.get_active_profile(actor.actor_id, "worker") + if profile is None: + raise WorkerEligibilityRequired("active worker profile is required to claim task") return profile - async def ensure_worker_profile( - self, - actor: ActorContext, - payload: WorkerProfileUpsertRequest, - ) -> WorkerProfileResponse: - """Create or refresh a worker profile for the current actor. - - Args: - actor: Verified Flow actor context. - payload: Worker-controlled skill tags for task matching. - - Returns: - Persisted active worker profile response. - """ - require_any_role(actor, {"worker"}) - profile = await self._repo.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 self._session.commit() - await self._session.refresh(profile) - return WorkerProfileResponse.model_validate(profile) - - async def ensure_reviewer_profile( - self, - actor: ActorContext, - skill_tags: list[str], - ) -> ReviewerProfile: - """Create or refresh a reviewer profile for the current actor. - - Args: - actor: Verified Flow actor context. - skill_tags: Skill tags to associate with the reviewer. - - Returns: - Persisted reviewer profile. - """ - require_any_role(actor, {"admin", "project_manager", "reviewer"}) - return await self._repo.upsert_reviewer_profile( - ReviewerProfile( - 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=skill_tags, - status="active", - ) - ) - async def _change_task_status( self, actor: ActorContext, diff --git a/backend/scripts/week1_api_e2e.py b/backend/scripts/week1_api_e2e.py index bd1bb8d..963d00b 100644 --- a/backend/scripts/week1_api_e2e.py +++ b/backend/scripts/week1_api_e2e.py @@ -219,7 +219,6 @@ def api_environment() -> dict[str, str]: 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["WORKSTREAM_PROJECT_SETUP_PIPELINE_AUTOSTART"] = "false" env["WORKSTREAM_CELERY_TASK_ALWAYS_EAGER"] = "true" env["WORKSTREAM_CELERY_BROKER_URL"] = "memory://" @@ -842,10 +841,9 @@ async def exercise_week1_api(base_url: str, env: dict[str, str]) -> None: worker_profile = await request_json( client, "POST", - "/api/v1/demo/worker-profile", + "/api/v1/workers/me/profile", worker_token, {"skill_tags": ["stem", "proofs"]}, - 201, ) assert worker_profile["external_subject"] == worker_subject assert worker_profile["external_issuer"] == flow_issuer diff --git a/backend/scripts/week1_dry_run.py b/backend/scripts/week1_dry_run.py index e79042f..1317a51 100644 --- a/backend/scripts/week1_dry_run.py +++ b/backend/scripts/week1_dry_run.py @@ -6,17 +6,14 @@ import hashlib import os from pathlib import Path -from uuid import uuid4 from alembic import command from alembic.config import Config from httpx import ASGITransport, AsyncClient -from app.adapters.auth.dev import actor_id_from_external_identity from app.core.config import get_settings from app.db import session as db_session from app.main import create_app -from app.modules.tasks.models import WorkerProfile TOKEN = "week1-dry-run-token" ISSUER = "flow-dry-run" @@ -67,33 +64,6 @@ def alembic_config() -> Config: return config -async def seed_worker_profile(subject: str) -> str: - """Create the active worker profile required before claim. - - Args: - subject: External Flow subject for the worker. - - Returns: - Stable Workstream actor id for the worker. - """ - worker_actor_id = actor_id_from_external_identity(ISSUER, subject) - async with db_session.get_session_factory()() as session: - session.add( - WorkerProfile( - id=str(uuid4()), - actor_id=worker_actor_id, - external_subject=subject, - external_issuer=ISSUER, - display_name=subject.replace("-", " ").title(), - email=f"{subject}@flow.local", - skill_tags=["stem", "proofs"], - status="active", - ) - ) - await session.commit() - return worker_actor_id - - async def post_ok(client: AsyncClient, url: str, payload: dict | None = None) -> dict: """POST JSON and return a successful response body. @@ -284,8 +254,12 @@ async def main() -> None: {"reason": "dry-run release decision"}, ) - await seed_worker_profile(worker_subject) set_actor(subject=worker_subject, roles="worker") + await post_ok( + client, + "/api/v1/workers/me/profile", + {"skill_tags": ["stem", "proofs"]}, + ) claim = await post_ok( client, f"/api/v1/tasks/{task['id']}/claim", diff --git a/backend/scripts/week2_api_e2e.py b/backend/scripts/week2_api_e2e.py index 55defb8..51b705c 100644 --- a/backend/scripts/week2_api_e2e.py +++ b/backend/scripts/week2_api_e2e.py @@ -418,10 +418,9 @@ async def create_started_task( worker_profile = await request_json( client, "POST", - "/api/v1/demo/worker-profile", + "/api/v1/workers/me/profile", worker_token, {"skill_tags": ["stem", "proofs"]}, - 201, ) ensure(worker_profile["external_subject"] == worker_subject, "worker subject mismatch") ensure(worker_profile["external_issuer"] == flow_issuer, "worker issuer mismatch") @@ -1760,10 +1759,9 @@ async def exercise_week2_api(base_url: str, env: dict[str, str]) -> None: await request_json( client, "POST", - "/api/v1/demo/worker-profile", + "/api/v1/workers/me/profile", unassigned_worker_token, {"skill_tags": ["stem", "proofs"]}, - 201, ) denied_snapshot = await task_side_effect_snapshot(checker_revision_task["id"]) await request_json( diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py new file mode 100644 index 0000000..988a372 --- /dev/null +++ b/backend/tests/test_actors.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Iterator +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.adapters.auth.dev import actor_id_from_external_identity +from app.core.config import get_settings +from app.db import session as db_session +from app.main import create_app +from app.modules.actors.models import ActorIdentity, ActorProfile +from app.modules.actors.schemas import ActorProfileActivationRequest +from app.modules.actors.service import ActorService +from app.modules.tasks.models import AuditEvent +from app.schemas.auth import ActorContext + + +@pytest.fixture +def actor_database_env( + monkeypatch: pytest.MonkeyPatch, + postgres_database_url: str, + migration_lock, +) -> Iterator[str]: + """Run actor registry tests against a migrated Postgres schema.""" + monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) + set_dev_actor(monkeypatch, roles="worker", subject="actor-registry-worker") + get_settings.cache_clear() + asyncio.run(db_session.dispose_engine()) + + config = alembic_config() + with migration_lock(): + command.downgrade(config, "base") + command.upgrade(config, "head") + yield postgres_database_url + command.downgrade(config, "base") + asyncio.run(db_session.dispose_engine()) + get_settings.cache_clear() + + +@pytest.fixture +async def actor_client(actor_database_env: str) -> AsyncIterator[AsyncClient]: + """Yield an in-process client backed by the migrated actor database.""" + app = create_app() + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + yield client + + +def alembic_config() -> Config: + """Return Alembic configuration for backend migrations.""" + project_root = Path(__file__).resolve().parents[1] + config = Config(str(project_root / "alembic.ini")) + config.set_main_option("script_location", str(project_root / "alembic")) + return config + + +def set_dev_actor( + monkeypatch: pytest.MonkeyPatch, + *, + roles: str, + subject: str, + token: str = "actor-token", + issuer: str = "flow-test", + email: str | None = None, + display_name: str | None = None, +) -> None: + """Configure the development verifier for one actor.""" + monkeypatch.setenv("WORKSTREAM_AUTH_PROVIDER", "dev") + monkeypatch.setenv("WORKSTREAM_ENVIRONMENT", "test") + monkeypatch.setenv("WORKSTREAM_DEV_AUTH_TOKEN", token) + monkeypatch.setenv("WORKSTREAM_DEV_AUTH_SUBJECT", subject) + monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ISSUER", issuer) + monkeypatch.setenv("WORKSTREAM_DEV_AUTH_EMAIL", email or f"{subject}@example.test") + monkeypatch.setenv( + "WORKSTREAM_DEV_AUTH_DISPLAY_NAME", + display_name or subject.replace("-", " ").title(), + ) + monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ROLES", roles) + get_settings.cache_clear() + + +def auth_headers(token: str = "actor-token") -> dict[str, str]: + """Return bearer auth headers for actor registry tests.""" + return {"Authorization": f"Bearer {token}"} + + +def actor_id(subject: str, issuer: str = "flow-test") -> str: + """Return the stable actor id for a test issuer and subject.""" + return actor_id_from_external_identity(issuer, subject) + + +def actor_context( + *, + subject: str, + roles: tuple[str, ...], + claim_snapshot: dict | None = None, +) -> ActorContext: + """Build a trusted actor context for service-level actor tests.""" + return ActorContext( + actor_id=actor_id(subject), + external_subject=subject, + external_issuer="flow-test", + email=f"{subject}@example.test", + display_name=subject.replace("-", " ").title(), + roles=roles, + claim_snapshot=claim_snapshot or {"roles": roles}, + auth_source="dev_mock", + is_dev_auth=True, + ) + + +async def test_auth_me_registers_identity_and_observed_profiles_without_duplicates( + actor_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + set_dev_actor(monkeypatch, roles="worker,reviewer", subject="observed-actor") + + first = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) + second = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) + + assert first.status_code == 200, first.text + assert second.status_code == 200, second.text + async with db_session.get_session_factory()() as session: + identity_rows = ( + await session.execute( + select(ActorIdentity).where(ActorIdentity.actor_id == actor_id("observed-actor")) + ) + ).scalars().all() + profile_rows = ( + await session.execute( + select(ActorProfile) + .where(ActorProfile.actor_id == actor_id("observed-actor")) + .order_by(ActorProfile.profile_type.asc()) + ) + ).scalars().all() + + assert len(identity_rows) == 1 + assert identity_rows[0].last_seen_roles == ["worker", "reviewer"] + assert [(profile.profile_type, profile.status) for profile in profile_rows] == [ + ("reviewer", "observed"), + ("worker", "observed"), + ] + + +async def test_repeated_auth_me_refreshes_profile_freshness_without_status_drift( + actor_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + set_dev_actor(monkeypatch, roles="worker", subject="freshness-worker") + first = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) + assert first.status_code == 200, first.text + stale_time = datetime.now(UTC) - timedelta(days=1) + async with db_session.get_session_factory()() as session: + profile = await session.scalar( + select(ActorProfile).where( + ActorProfile.actor_id == actor_id("freshness-worker"), + ActorProfile.profile_type == "worker", + ) + ) + assert profile is not None + profile.updated_at = stale_time + await session.commit() + + second = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) + assert second.status_code == 200, second.text + async with db_session.get_session_factory()() as session: + profile_rows = ( + await session.execute( + select(ActorProfile).where( + ActorProfile.actor_id == actor_id("freshness-worker"), + ActorProfile.profile_type == "worker", + ) + ) + ).scalars().all() + + assert len(profile_rows) == 1 + assert profile_rows[0].status == "observed" + assert profile_rows[0].updated_at > stale_time + + +@pytest.mark.parametrize("role", ["worker", "reviewer", "admin", "project_manager"]) +async def test_token_roles_create_observed_non_eligibility_profiles( + actor_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + role: str, +) -> None: + subject = f"{role}-observed" + set_dev_actor(monkeypatch, roles=role, subject=subject) + + response = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) + + assert response.status_code == 200, response.text + async with db_session.get_session_factory()() as session: + profile = await session.scalar( + select(ActorProfile).where( + ActorProfile.actor_id == actor_id(subject), + ActorProfile.profile_type == role, + ) + ) + + assert profile is not None + assert profile.status == "observed" + assert profile.scope_type == "global" + assert profile.scope_id == "global" + + +async def test_worker_profile_activation_is_explicit_and_audited( + actor_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + set_dev_actor(monkeypatch, roles="worker", subject="explicit-worker") + + response = await actor_client.post( + "/api/v1/workers/me/profile", + headers=auth_headers(), + json={ + "skill_tags": [" STEM ", "stem", "finance"], + "email": "spoof@example.test", + }, + ) + assert response.status_code == 422 + + profile_response = await actor_client.post( + "/api/v1/workers/me/profile", + headers=auth_headers(), + json={"skill_tags": [" STEM ", "stem", "finance"]}, + ) + + assert profile_response.status_code == 200, profile_response.text + body = profile_response.json() + assert body["profile_type"] == "worker" + assert body["status"] == "active" + assert body["skill_tags"] == ["stem", "finance"] + assert body["email"] == "explicit-worker@example.test" + async with db_session.get_session_factory()() as session: + events = ( + await session.execute( + select(AuditEvent).where( + AuditEvent.entity_type == "actor_profile", + AuditEvent.entity_id == body["id"], + ) + ) + ).scalars().all() + + assert any(event.event_type == "actor_profile_activated" for event in events) + + +async def test_observation_preserves_active_and_disabled_statuses( + actor_database_env: str, +) -> None: + active_actor = actor_context(subject="active-preserved", roles=("worker",)) + disabled_actor = actor_context(subject="disabled-preserved", roles=("worker",)) + async with db_session.get_session_factory()() as session: + service = ActorService(session) + await service.activate_worker_profile( + active_actor, + ActorProfileActivationRequest(skill_tags=["stem"]), + ) + await service.activate_worker_profile( + disabled_actor, + ActorProfileActivationRequest(skill_tags=["stem"]), + ) + disabled_profile = await service.get_active_profile(disabled_actor.actor_id, "worker") + assert disabled_profile is not None + disabled_profile.status = "disabled" + await session.commit() + + await service.register_actor(active_actor) + await service.register_actor(disabled_actor) + + async with db_session.get_session_factory()() as session: + active_profile = await session.scalar( + select(ActorProfile).where(ActorProfile.actor_id == active_actor.actor_id) + ) + disabled_profile = await session.scalar( + select(ActorProfile).where(ActorProfile.actor_id == disabled_actor.actor_id) + ) + + assert active_profile is not None + assert active_profile.status == "active" + assert active_profile.profile_metadata == {"source": "worker_profile_api"} + assert disabled_profile is not None + assert disabled_profile.status == "disabled" + assert disabled_profile.profile_metadata == {"source": "worker_profile_api"} + + +async def test_scoped_project_owner_profile_comes_from_trusted_relationship_claim( + actor_database_env: str, +) -> None: + actor = actor_context( + subject="source-contact", + roles=("project_manager",), + claim_snapshot={ + "workstream_relationship_profiles": [ + { + "profile_type": "project_owner", + "scope_type": "project", + "scope_id": "project-123", + "profile_metadata": {"organization": "Example Labs"}, + } + ] + }, + ) + async with db_session.get_session_factory()() as session: + service = ActorService(session) + await service.register_actor(actor) + await service.register_actor(actor) + + async with db_session.get_session_factory()() as session: + profiles = ( + await session.execute( + select(ActorProfile).where( + ActorProfile.actor_id == actor.actor_id, + ActorProfile.profile_type == "project_owner", + ) + ) + ).scalars().all() + + assert len(profiles) == 1 + assert profiles[0].status == "observed" + assert profiles[0].scope_type == "project" + assert profiles[0].scope_id == "project-123" + assert profiles[0].profile_metadata["organization"] == "Example Labs" + + +async def test_active_profile_without_matching_token_role_cannot_use_worker_profile_api( + actor_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + set_dev_actor(monkeypatch, roles="worker", subject="role-lost-worker") + created = await actor_client.post( + "/api/v1/workers/me/profile", + headers=auth_headers(), + json={"skill_tags": ["stem"]}, + ) + assert created.status_code == 200, created.text + + set_dev_actor(monkeypatch, roles="project_manager", subject="role-lost-worker") + denied = await actor_client.post( + "/api/v1/workers/me/profile", + headers=auth_headers(), + json={"skill_tags": ["stem"]}, + ) + + assert denied.status_code == 403 diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 58a8b51..a25d013 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import json from pathlib import Path from uuid import uuid4 @@ -8,8 +9,11 @@ from alembic import command from alembic.config import Config from sqlalchemy import text +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import create_async_engine +from app.adapters.auth.dev import actor_id_from_external_identity + def test_alembic_upgrade_and_downgrade(isolated_database_env: str, migration_lock) -> None: project_root = Path(__file__).resolve().parents[1] config = Config(str(project_root / "alembic.ini")) @@ -143,6 +147,90 @@ def test_post_submit_policy_upgrade_blocks_pre_provenance_runtime_rows( assert columns_exist is False +def test_actor_profile_registry_backfills_and_removes_legacy_profile_tables( + isolated_database_env: str, + migration_lock, +) -> None: + """Prove legacy profile rows move into the shared actor registry.""" + project_root = Path(__file__).resolve().parents[1] + config = Config(str(project_root / "alembic.ini")) + config.set_main_option("script_location", str(project_root / "alembic")) + + worker_actor_id = actor_id_from_external_identity("flow-test", "legacy-worker") + reviewer_actor_id = actor_id_from_external_identity("flow-test", "legacy-reviewer") + ids = { + "worker": str(uuid4()), + "reviewer": str(uuid4()), + "worker_actor": worker_actor_id, + "reviewer_actor": reviewer_actor_id, + } + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "0011_task_artifact_cleanup") + asyncio.run(_seed_legacy_profile_rows(isolated_database_env, ids)) + command.upgrade(config, "head") + head_state = asyncio.run(_fetch_actor_registry_state(isolated_database_env)) + + command.downgrade(config, "0011_task_artifact_cleanup") + downgraded_tables = asyncio.run(_fetch_table_names(isolated_database_env)) + downgraded_state = asyncio.run(_fetch_legacy_profile_state(isolated_database_env)) + finally: + command.downgrade(config, "base") + + assert "worker_profiles" not in head_state["tables"] + assert "reviewer_profiles" not in head_state["tables"] + assert head_state["identities"] == { + ("legacy-worker", "flow-test"), + ("legacy-reviewer", "flow-test"), + } + assert head_state["profiles"] == { + ("legacy-worker", "worker", "active", ("stem", "python"), "global", "global"), + ("legacy-reviewer", "reviewer", "active", ("review",), "global", "global"), + } + assert {"worker_profiles", "reviewer_profiles"}.issubset(downgraded_tables) + assert downgraded_state["worker_profiles"] == { + ( + worker_actor_id, + "legacy-worker", + "flow-test", + "Legacy Worker", + "legacy-worker@example.test", + ("stem", "python"), + "active", + ) + } + assert downgraded_state["reviewer_profiles"] == { + ( + reviewer_actor_id, + "legacy-reviewer", + "flow-test", + "Legacy Reviewer", + "legacy-reviewer@example.test", + ("review",), + "active", + ) + } + + +def test_actor_profile_registry_unique_constraints_are_enforced( + isolated_database_env: str, + migration_lock, +) -> None: + """Prove actor registry uniqueness is enforced by Postgres.""" + project_root = Path(__file__).resolve().parents[1] + config = Config(str(project_root / "alembic.ini")) + config.set_main_option("script_location", str(project_root / "alembic")) + + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") + asyncio.run(_assert_actor_registry_unique_constraints(isolated_database_env)) + finally: + command.downgrade(config, "base") + + async def _fetch_columns(database_url: str) -> set[str]: """Return current public table columns as table.column names.""" engine = create_async_engine(database_url) @@ -164,6 +252,383 @@ async def _fetch_columns(database_url: str) -> set[str]: await engine.dispose() +async def _fetch_table_names(database_url: str) -> set[str]: + """Return current public table names.""" + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + rows = ( + await connection.execute( + text( + """ + select table_name + from information_schema.tables + where table_schema = 'public' + """ + ) + ) + ).all() + return {row.table_name for row in rows} + finally: + await engine.dispose() + + +async def _seed_legacy_profile_rows(database_url: str, ids: dict[str, str]) -> None: + """Seed old profile tables before the actor registry migration.""" + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute( + text( + """ + insert into worker_profiles ( + id, + actor_id, + external_subject, + external_issuer, + display_name, + email, + skill_tags, + status + ) + values ( + :id, + :actor_id, + 'legacy-worker', + 'flow-test', + 'Legacy Worker', + 'legacy-worker@example.test', + cast(:skill_tags as json), + 'active' + ) + """ + ), + { + "id": ids["worker"], + "actor_id": ids["worker_actor"], + "skill_tags": json.dumps(["stem", "python"]), + }, + ) + await connection.execute( + text( + """ + insert into reviewer_profiles ( + id, + actor_id, + external_subject, + external_issuer, + display_name, + email, + skill_tags, + status + ) + values ( + :id, + :actor_id, + 'legacy-reviewer', + 'flow-test', + 'Legacy Reviewer', + 'legacy-reviewer@example.test', + cast(:skill_tags as json), + 'active' + ) + """ + ), + { + "id": ids["reviewer"], + "actor_id": ids["reviewer_actor"], + "skill_tags": json.dumps(["review"]), + }, + ) + finally: + await engine.dispose() + + +async def _assert_actor_registry_unique_constraints(database_url: str) -> None: + """Insert duplicates and prove actor registry unique constraints reject them.""" + engine = create_async_engine(database_url) + actor_id = actor_id_from_external_identity("flow-test", "unique-actor") + try: + async with engine.begin() as connection: + await connection.execute( + text( + """ + insert into actor_identities ( + actor_id, + external_subject, + external_issuer, + display_name, + email, + last_seen_roles, + last_claim_snapshot, + auth_source, + is_dev_auth + ) + values ( + :actor_id, + 'unique-actor', + 'flow-test', + 'Unique Actor', + 'unique@example.test', + cast(:roles as json), + cast(:claim_snapshot as json), + 'dev_mock', + true + ) + """ + ), + { + "actor_id": actor_id, + "roles": json.dumps(["worker"]), + "claim_snapshot": json.dumps({"roles": ["worker"]}), + }, + ) + await connection.execute( + text( + """ + insert into actor_profiles ( + id, + actor_id, + profile_type, + status, + skill_tags, + scope_type, + scope_id, + profile_metadata + ) + values ( + :id, + :actor_id, + 'worker', + 'observed', + cast(:skill_tags as json), + 'global', + 'global', + cast(:profile_metadata as json) + ) + """ + ), + { + "id": str(uuid4()), + "actor_id": actor_id, + "skill_tags": json.dumps([]), + "profile_metadata": json.dumps({}), + }, + ) + + duplicate_actor_id = text( + """ + insert into actor_identities ( + actor_id, + external_subject, + external_issuer, + last_seen_roles, + last_claim_snapshot, + auth_source, + is_dev_auth + ) + values ( + :actor_id, + 'different-subject', + 'flow-test', + cast(:roles as json), + cast(:claim_snapshot as json), + 'dev_mock', + true + ) + """ + ) + duplicate_external_identity = text( + """ + insert into actor_identities ( + actor_id, + external_subject, + external_issuer, + last_seen_roles, + last_claim_snapshot, + auth_source, + is_dev_auth + ) + values ( + :actor_id, + 'unique-actor', + 'flow-test', + cast(:roles as json), + cast(:claim_snapshot as json), + 'dev_mock', + true + ) + """ + ) + duplicate_profile_scope = text( + """ + insert into actor_profiles ( + id, + actor_id, + profile_type, + status, + skill_tags, + scope_type, + scope_id, + profile_metadata + ) + values ( + :id, + :actor_id, + 'worker', + 'observed', + cast(:skill_tags as json), + 'global', + 'global', + cast(:profile_metadata as json) + ) + """ + ) + await _expect_integrity_error( + engine, + duplicate_actor_id, + { + "actor_id": actor_id, + "roles": json.dumps([]), + "claim_snapshot": json.dumps({}), + }, + ) + await _expect_integrity_error( + engine, + duplicate_external_identity, + { + "actor_id": actor_id_from_external_identity("flow-test", "other-unique-actor"), + "roles": json.dumps([]), + "claim_snapshot": json.dumps({}), + }, + ) + await _expect_integrity_error( + engine, + duplicate_profile_scope, + { + "id": str(uuid4()), + "actor_id": actor_id, + "skill_tags": json.dumps([]), + "profile_metadata": json.dumps({}), + }, + ) + finally: + await engine.dispose() + + +async def _expect_integrity_error(engine, statement, params: dict) -> None: + """Assert that one SQL statement raises a database integrity error.""" + with pytest.raises(IntegrityError): + async with engine.begin() as connection: + await connection.execute(statement, params) + + +async def _fetch_actor_registry_state(database_url: str) -> dict[str, set]: + """Return actor registry rows and table names for migration assertions.""" + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + tables = await _fetch_table_names(database_url) + identities = ( + await connection.execute( + text( + """ + select external_subject, external_issuer + from actor_identities + order by external_subject + """ + ) + ) + ).all() + profiles = ( + await connection.execute( + text( + """ + select + i.external_subject, + p.profile_type, + p.status, + p.skill_tags, + p.scope_type, + p.scope_id + from actor_profiles p + join actor_identities i on i.actor_id = p.actor_id + order by i.external_subject, p.profile_type + """ + ) + ) + ).all() + return { + "tables": tables, + "identities": { + (row.external_subject, row.external_issuer) + for row in identities + }, + "profiles": { + ( + row.external_subject, + row.profile_type, + row.status, + tuple(row.skill_tags), + row.scope_type, + row.scope_id, + ) + for row in profiles + }, + } + finally: + await engine.dispose() + + +async def _fetch_legacy_profile_state(database_url: str) -> dict[str, set]: + """Return restored legacy profile rows after actor registry downgrade.""" + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + worker_rows = await _fetch_legacy_profile_rows(connection, "worker_profiles") + reviewer_rows = await _fetch_legacy_profile_rows(connection, "reviewer_profiles") + return { + "worker_profiles": worker_rows, + "reviewer_profiles": reviewer_rows, + } + finally: + await engine.dispose() + + +async def _fetch_legacy_profile_rows(connection, table_name: str) -> set[tuple]: + """Return comparable legacy profile rows from one restored table.""" + rows = ( + await connection.execute( + text( + f""" + select + actor_id, + external_subject, + external_issuer, + display_name, + email, + skill_tags, + status + from {table_name} + order by external_subject + """ + ) + ) + ).all() + return { + ( + row.actor_id, + row.external_subject, + row.external_issuer, + row.display_name, + row.email, + tuple(row.skill_tags), + row.status, + ) + for row in rows + } + + async def _seed_pre_provenance_post_submit_policy( database_url: str, project_id: str, diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index ce889c2..8e46a3b 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,12 +1,19 @@ from __future__ import annotations +import asyncio +from collections.abc import Iterator +from pathlib import Path + import pytest +from alembic import command +from alembic.config import Config from httpx import ASGITransport, AsyncClient from app.adapters.auth.dev import DevelopmentAuthVerifier from app.adapters.auth.flow import FlowAuthVerifier from app.core.config import Settings, get_settings from app.core.permissions import PermissionDenied, require_any_role +from app.db import session as db_session from app.interfaces.auth import AuthVerificationError from app.main import create_app @@ -31,6 +38,29 @@ def clear_settings_cache() -> None: get_settings.cache_clear() +@pytest.fixture +def auth_database_env( + monkeypatch: pytest.MonkeyPatch, + postgres_database_url: str, + migration_lock, +) -> Iterator[str]: + """Run auth route persistence tests against a migrated Postgres schema.""" + monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) + get_settings.cache_clear() + asyncio.run(db_session.dispose_engine()) + + project_root = Path(__file__).resolve().parents[1] + config = Config(str(project_root / "alembic.ini")) + config.set_main_option("script_location", str(project_root / "alembic")) + with migration_lock(): + command.downgrade(config, "base") + command.upgrade(config, "head") + yield postgres_database_url + command.downgrade(config, "base") + asyncio.run(db_session.dispose_engine()) + get_settings.cache_clear() + + async def test_missing_bearer_token_is_rejected() -> None: app = create_app() @@ -63,6 +93,7 @@ async def test_invalid_bearer_token_is_rejected() -> None: async def test_valid_dev_token_resolves_actor_context( monkeypatch: pytest.MonkeyPatch, + auth_database_env: str, ) -> None: monkeypatch.setenv("WORKSTREAM_AUTH_PROVIDER", "dev") monkeypatch.setenv("WORKSTREAM_ENVIRONMENT", "local") diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index a01f8ef..9a86d29 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -24,23 +24,24 @@ from app.db import session as db_session from app.db.base import Base from app.main import create_app -from app.modules.tasks.lifecycle import InvalidTaskTransition, ensure_allowed_transition +from app.modules.actors.models import ActorIdentity, ActorProfile +from app.modules.actors.schemas import ActorProfileActivationRequest +from app.modules.actors.service import ActorService from app.modules.projects.models import ( EffectiveProjectSubmissionArtifactPolicy, PostSubmitCheckerPolicy, PreSubmitCheckerPolicy, SubmissionArtifactPolicy, ) +from app.modules.tasks.lifecycle import InvalidTaskTransition, ensure_allowed_transition from app.modules.tasks.models import ( AuditEvent, EvidenceItem, - ReviewerProfile, Submission, TaskAssignment, - WorkerProfile, WorkstreamTask, ) -from app.modules.tasks.repository import TaskRepository +from app.schemas.auth import ActorContext @pytest.fixture @@ -124,6 +125,21 @@ def actor_id(subject: str, issuer: str = "flow-test") -> str: return actor_id_from_external_identity(issuer, subject) +async def fetch_actor_registry_rows(subject: str, issuer: str = "flow-test") -> tuple[ActorIdentity | None, list[ActorProfile]]: + """Load actor registry rows for assertions.""" + expected_actor_id = actor_id(subject, issuer) + async with db_session.get_session_factory()() as session: + identity = await session.get(ActorIdentity, expected_actor_id) + profiles = ( + await session.scalars( + select(ActorProfile) + .where(ActorProfile.actor_id == expected_actor_id) + .order_by(ActorProfile.profile_type.asc(), ActorProfile.scope_type.asc()) + ) + ).all() + return identity, list(profiles) + + def complete_guide_payload(version: str = "v1") -> dict: return { "version": version, @@ -437,26 +453,78 @@ async def create_started_task( async def seed_worker_profile(subject: str, *, skill_tags: list[str] | None = None) -> str: worker_actor_id = actor_id(subject) async with db_session.get_session_factory()() as session: - session.add( - WorkerProfile( - id=str(uuid4()), - actor_id=worker_actor_id, - external_subject=subject, - external_issuer="flow-test", - display_name=subject.replace("-", " ").title(), - email=f"{subject}@example.test", - skill_tags=skill_tags or ["stem"], - status="active", - ) + session.add_all( + [ + ActorIdentity( + actor_id=worker_actor_id, + external_subject=subject, + external_issuer="flow-test", + display_name=subject.replace("-", " ").title(), + email=f"{subject}@example.test", + last_seen_roles=["worker"], + last_claim_snapshot={"seeded_for_task_test": True}, + auth_source="dev_mock", + is_dev_auth=True, + ), + ActorProfile( + id=str(uuid4()), + actor_id=worker_actor_id, + profile_type="worker", + status="active", + skill_tags=skill_tags or ["stem"], + scope_type="global", + scope_id="global", + profile_metadata={"seeded_for_task_test": True}, + ), + ] ) await session.commit() return worker_actor_id +async def seed_actor_profile( + subject: str, + *, + profile_type: str, + status: str = "active", + skill_tags: list[str] | None = None, +) -> str: + """Seed one actor identity and global actor profile for authorization tests.""" + seeded_actor_id = actor_id(subject) + async with db_session.get_session_factory()() as session: + session.add_all( + [ + ActorIdentity( + actor_id=seeded_actor_id, + external_subject=subject, + external_issuer="flow-test", + display_name=subject.replace("-", " ").title(), + email=f"{subject}@example.test", + last_seen_roles=[profile_type], + last_claim_snapshot={"seeded_for_task_test": True}, + auth_source="dev_mock", + is_dev_auth=True, + ), + ActorProfile( + id=str(uuid4()), + actor_id=seeded_actor_id, + profile_type=profile_type, + status=status, + skill_tags=skill_tags or [], + scope_type="global", + scope_id="global", + profile_metadata={"seeded_for_task_test": True}, + ), + ] + ) + await session.commit() + return seeded_actor_id + + def test_task_models_are_registered_for_alembic_metadata() -> None: expected_tables = { - "worker_profiles", - "reviewer_profiles", + "actor_identities", + "actor_profiles", "workstream_tasks", "task_assignments", "submissions", @@ -465,8 +533,12 @@ def test_task_models_are_registered_for_alembic_metadata() -> None: } assert expected_tables.issubset(Base.metadata.tables) - assert db_models.WorkerProfile is WorkerProfile - assert db_models.ReviewerProfile is ReviewerProfile + assert "worker_profiles" not in Base.metadata.tables + assert "reviewer_profiles" not in Base.metadata.tables + assert db_models.ActorIdentity is ActorIdentity + assert db_models.ActorProfile is ActorProfile + assert not hasattr(db_models, "WorkerProfile") + assert not hasattr(db_models, "ReviewerProfile") assert db_models.WorkstreamTask is WorkstreamTask assert db_models.TaskAssignment is TaskAssignment assert db_models.Submission is Submission @@ -481,8 +553,8 @@ async def test_chunk4_migration_creates_expected_tables(task_database_env: str) ) assert { - "worker_profiles", - "reviewer_profiles", + "actor_identities", + "actor_profiles", "workstream_tasks", "task_assignments", "submissions", @@ -506,8 +578,8 @@ async def inspect_tables() -> set[str]: assert "projects" in table_names assert { - "worker_profiles", - "reviewer_profiles", + "actor_identities", + "actor_profiles", "workstream_tasks", "task_assignments", "submissions", @@ -630,71 +702,78 @@ def test_task_locked_context_constraints_bind_task_submission_and_hashes() -> No } -async def test_profile_upserts_update_existing_actor_rows(task_database_env: str) -> None: +async def test_actor_profile_services_update_existing_actor_rows(task_database_env: str) -> None: async with db_session.get_session_factory()() as session: - repository = TaskRepository(session) - worker_actor_id = actor_id("worker-upsert") - first_worker = await repository.upsert_worker_profile( - WorkerProfile( - id=str(uuid4()), - actor_id=worker_actor_id, - external_subject="worker-upsert", - external_issuer="flow-test", - display_name="Worker Upsert", - email="worker-upsert@example.test", - skill_tags=["stem"], - status="active", - ) + service = ActorService(session) + worker_actor = ActorContext( + actor_id=actor_id("worker-upsert"), + external_subject="worker-upsert", + external_issuer="flow-test", + display_name="Worker Upsert", + email="worker-upsert@example.test", + roles=("worker",), + claim_snapshot={"roles": ("worker",)}, + auth_source="dev_mock", + is_dev_auth=True, ) - updated_worker = await repository.upsert_worker_profile( - WorkerProfile( - id=str(uuid4()), - actor_id=worker_actor_id, - external_subject="worker-upsert", - external_issuer="flow-test", - display_name="Worker Updated", - email="worker-updated@example.test", - skill_tags=["stem", "analysis"], - status="active", - ) + first_worker = await service.activate_worker_profile( + worker_actor, + ActorProfileActivationRequest(skill_tags=["stem"]), + ) + updated_worker = await service.activate_worker_profile( + worker_actor.model_copy( + update={"display_name": "Worker Updated", "email": "worker-updated@example.test"} + ), + ActorProfileActivationRequest(skill_tags=["stem", "analysis"]), ) - reviewer_actor_id = actor_id("reviewer-upsert") - first_reviewer = await repository.upsert_reviewer_profile( - ReviewerProfile( - id=str(uuid4()), - actor_id=reviewer_actor_id, - external_subject="reviewer-upsert", - external_issuer="flow-test", - display_name="Reviewer Upsert", - email="reviewer-upsert@example.test", - skill_tags=["review"], - status="active", - ) + reviewer_actor = ActorContext( + actor_id=actor_id("reviewer-upsert"), + external_subject="reviewer-upsert", + external_issuer="flow-test", + display_name="Reviewer Upsert", + email="reviewer-upsert@example.test", + roles=("reviewer",), + claim_snapshot={"roles": ("reviewer",)}, + auth_source="dev_mock", + is_dev_auth=True, ) - updated_reviewer = await repository.upsert_reviewer_profile( - ReviewerProfile( - id=str(uuid4()), - actor_id=reviewer_actor_id, - external_subject="reviewer-upsert", - external_issuer="flow-test", - display_name="Reviewer Updated", - email="reviewer-updated@example.test", - skill_tags=["review", "stem"], - status="active", - ) + first_reviewer = await service.ensure_observed_profile( + reviewer_actor, + profile_type="reviewer", + scope_type="global", + scope_id="global", + profile_metadata={"source": "test"}, + ) + updated_reviewer = await service.ensure_observed_profile( + reviewer_actor.model_copy( + update={ + "display_name": "Reviewer Updated", + "email": "reviewer-updated@example.test", + } + ), + profile_type="reviewer", + scope_type="global", + scope_id="global", + profile_metadata={"source": "test_refresh"}, ) await session.commit() async with db_session.get_session_factory()() as session: worker_rows = ( await session.execute( - select(WorkerProfile).where(WorkerProfile.actor_id == worker_actor_id) + select(ActorProfile).where( + ActorProfile.actor_id == worker_actor.actor_id, + ActorProfile.profile_type == "worker", + ) ) ).scalars().all() reviewer_rows = ( await session.execute( - select(ReviewerProfile).where(ReviewerProfile.actor_id == reviewer_actor_id) + select(ActorProfile).where( + ActorProfile.actor_id == reviewer_actor.actor_id, + ActorProfile.profile_type == "reviewer", + ) ) ).scalars().all() @@ -704,8 +783,8 @@ async def test_profile_upserts_update_existing_actor_rows(task_database_env: str assert len(worker_rows) == 1 assert worker_rows[0].id == first_worker.id assert updated_reviewer.id == first_reviewer.id - assert updated_reviewer.display_name == "Reviewer Updated" - assert updated_reviewer.skill_tags == ["review", "stem"] + assert updated_reviewer.status == "observed" + assert updated_reviewer.profile_metadata == {"source": "test_refresh"} assert len(reviewer_rows) == 1 assert reviewer_rows[0].id == first_reviewer.id @@ -1271,6 +1350,46 @@ async def test_worker_without_profile_cannot_claim_ready_task( assert "active worker profile" in response.json()["detail"] +async def test_active_worker_profile_without_worker_token_cannot_claim( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = await create_active_project(task_client) + ready_task = await create_ready_task(task_client, project["id"]) + await seed_worker_profile("worker-role-missing") + set_dev_actor(monkeypatch, roles="project_manager", subject="worker-role-missing") + + response = await task_client.post( + f"/api/v1/tasks/{ready_task['id']}/claim", + headers=auth_headers(), + json={"reason": "claim without worker token role"}, + ) + + assert response.status_code == 403 + assert "actor lacks required role" in response.json()["detail"] + + +@pytest.mark.parametrize("profile_type", ["admin", "project_manager"]) +async def test_active_operator_profile_without_matching_token_cannot_create_task( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + profile_type: str, +) -> None: + project = await create_active_project(task_client) + subject = f"{profile_type}-profile-only" + await seed_actor_profile(subject, profile_type=profile_type) + set_dev_actor(monkeypatch, roles="worker", subject=subject) + + response = await task_client.post( + f"/api/v1/projects/{project['id']}/tasks", + headers=auth_headers(), + json=complete_task_payload(), + ) + + assert response.status_code == 403 + assert "actor lacks required role" in response.json()["detail"] + + async def test_worker_can_create_profile_before_claiming_task( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, @@ -1344,16 +1463,45 @@ async def test_worker_profile_request_is_fail_closed_and_validated( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: - set_dev_actor(monkeypatch, roles="worker", subject="worker-profile-validation") + subject = "worker-profile-validation" + set_dev_actor(monkeypatch, roles="worker", subject=subject) + + spoofed_fields = { + "actor_id": actor_id("malicious"), + "external_subject": "spoofed-subject", + "external_issuer": "spoofed-issuer", + "roles": ["admin"], + "email": "spoofed@example.test", + "display_name": "Spoofed Name", + } + for field_name, field_value in spoofed_fields.items(): + unknown_field = await task_client.post( + "/api/v1/workers/me/profile", + headers=auth_headers(), + json={ + "skill_tags": ["stem"], + field_name: field_value, + }, + ) + assert unknown_field.status_code == 422 + assert field_name in unknown_field.text + + identity, profiles = await fetch_actor_registry_rows(subject) + malicious_identity, malicious_profiles = await fetch_actor_registry_rows("malicious") + + assert malicious_identity is None + assert malicious_profiles == [] + assert identity is not None + assert identity.actor_id == actor_id(subject) + assert identity.external_subject == subject + assert identity.external_issuer == "flow-test" + assert identity.email == f"{subject}@example.test" + assert identity.display_name == "Worker Profile Validation" + assert identity.last_seen_roles == ["worker"] + assert [(profile.profile_type, profile.status, profile.scope_type, profile.scope_id) for profile in profiles] == [ + ("worker", "observed", "global", "global") + ] - unknown_field = await task_client.post( - "/api/v1/workers/me/profile", - headers=auth_headers(), - json={ - "skill_tags": ["stem"], - "actor_id": actor_id("malicious"), - }, - ) blank_tag = await task_client.post( "/api/v1/workers/me/profile", headers=auth_headers(), @@ -1365,14 +1513,60 @@ async def test_worker_profile_request_is_fail_closed_and_validated( json={"skill_tags": ["x" * 65]}, ) - assert unknown_field.status_code == 422 - assert "actor_id" in unknown_field.text assert blank_tag.status_code == 422 assert "skill_tags cannot include empty values" in blank_tag.text assert long_tag.status_code == 422 assert "skill_tags values must be 64 characters or fewer" in long_tag.text +async def test_registered_claim_route_rejects_identity_spoof_fields( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = await create_active_project(task_client) + ready_task = await create_ready_task(task_client, project["id"]) + set_dev_actor(monkeypatch, roles="worker", subject="worker-claim-overpost") + profile = await task_client.post( + "/api/v1/workers/me/profile", + headers=auth_headers(), + json={"skill_tags": ["stem"]}, + ) + assert profile.status_code == 200, profile.text + + spoofed_fields = { + "actor_id": actor_id("malicious"), + "external_subject": "spoofed-subject", + "external_issuer": "spoofed-issuer", + "roles": ["admin"], + "email": "spoofed@example.test", + "display_name": "Spoofed Name", + } + for field_name, field_value in spoofed_fields.items(): + response = await task_client.post( + f"/api/v1/tasks/{ready_task['id']}/claim", + headers=auth_headers(), + json={"reason": "claim", field_name: field_value}, + ) + assert response.status_code == 422 + assert field_name in response.text + + identity, profiles = await fetch_actor_registry_rows("worker-claim-overpost") + malicious_identity, malicious_profiles = await fetch_actor_registry_rows("malicious") + + assert malicious_identity is None + assert malicious_profiles == [] + assert identity is not None + assert identity.actor_id == actor_id("worker-claim-overpost") + assert identity.external_subject == "worker-claim-overpost" + assert identity.external_issuer == "flow-test" + assert identity.email == "worker-claim-overpost@example.test" + assert identity.display_name == "Worker Claim Overpost" + assert identity.last_seen_roles == ["worker"] + assert [(profile.profile_type, profile.status, profile.skill_tags) for profile in profiles] == [ + ("worker", "active", ["stem"]) + ] + + async def test_worker_profile_requires_worker_role( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, diff --git a/demos/week1_api_demo_ui/src/App.tsx b/demos/week1_api_demo_ui/src/App.tsx index 807f7a3..750acbf 100644 --- a/demos/week1_api_demo_ui/src/App.tsx +++ b/demos/week1_api_demo_ui/src/App.tsx @@ -257,17 +257,17 @@ export function App() { { key: "claim", label: "Worker Claims And Starts", - detail: "Activates demo worker profile, claims the task, then starts work.", + detail: "Activates worker profile, claims the task, then starts work.", icon: Users, run: async () => { if (!records.task) throw new Error("Create and release a task first."); const workerProfile = await call( "Activate worker profile", "POST", - "/api/v1/demo/worker-profile", + "/api/v1/workers/me/profile", actors.worker, { skill_tags: ["stem", "proofs"] }, - 201 + 200 ); const claimed = await call( "Claim task", @@ -421,10 +421,10 @@ export function App() { const workerProfile = await call( "Activate worker profile", "POST", - "/api/v1/demo/worker-profile", + "/api/v1/workers/me/profile", actors.worker, { skill_tags: ["stem", "proofs"] }, - 201 + 200 ); const claimed = await call( "Claim task", diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 3732fcf..8022592 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -58,8 +58,8 @@ Fields: - `last_seen_at` - `updated_at` -Actor identity comes from external Flow authentication. `external_subject` plus -`external_issuer` is the stable identity binding. Email is profile metadata and +Actor identity comes from external Flow authentication. `external_issuer` plus +`external_subject` is the stable identity binding. Email is profile metadata and must not be treated as the primary identity. Workstream keeps `ActorIdentity` rows for local workflow continuity, audit @@ -109,9 +109,17 @@ Auth observation alone may create `observed` profiles, but it must not mark a worker or reviewer profile `active`. Route access always comes from the current verified token roles, not from profile status. -`project_owner` is a scoped profile/contact relationship, not a route role. It -is created from trusted project setup or relationship claims when present; it is -not listed as a permission role and does not grant operator access. +`project_owner` is a scoped profile/contact relationship, not a route role. In +this chunk it is created from trusted relationship claims when present. Later +project setup/source-contact workflows may create the same scoped profile type +through an explicit trusted service path. It is not listed as a permission role +and does not grant operator access. + +The trusted relationship claim key is +`claim_snapshot["workstream_relationship_profiles"]`. Each item must use +`profile_type = "project_owner"`, a non-empty `scope_type`, a non-empty +`scope_id`, and optional object `profile_metadata`. These values are persisted +as observed relationship metadata only; they are not route authorization. Verified token roles: @@ -124,13 +132,13 @@ Verified token roles: `Operator` is a product persona, not a separate v0.1 permission role. In the application model, operator actions are performed by project managers, workers, reviewers, admins, or finance users depending on the action. -## WorkerProfile +## Worker Actor Profile Worker profile behavior is represented by `ActorProfile(profile_type="worker")`. The public worker profile API remains worker-owned, but the persistence model is the shared actor profile model. -## ReviewerProfile +## Reviewer Actor Profile Reviewer profile behavior is represented by `ActorProfile(profile_type="reviewer")`. Reviewer eligibility must be explicit; @@ -1313,26 +1321,30 @@ Fields: - `id` - `entity_type` - `entity_id` +- `event_type` +- `from_status` +- `to_status` - `actor_id` - `external_subject` - `external_issuer` -- `actor_role` +- `actor_roles` - `claim_snapshot` - `auth_source` -- `event_type` -- `before` -- `after` +- `is_dev_auth` - `reason` -- `locked_guide_version` -- `submission_id` -- `checker_run_id` -- `review_id` -- `contribution_record_id` -- `override_id` +- `event_payload` - `created_at` Audit events are append-only. +v0.1 audit storage is the existing Workstream `audit_events` ledger. Task +lifecycle events and actor profile eligibility events both write there so +operators can reconstruct why an actor was allowed to claim, submit, or later +review. Actor profile audit events use `entity_type = "actor_profile"` and +record profile type, scope, and skill/status details in `event_payload`. A +future shared audit module can move the code boundary out of the task module, +but this chunk does not create a second audit source of truth. + ## Required Invariants - a task must belong to a project diff --git a/docs/architecture_system_architecture.md b/docs/architecture_system_architecture.md index 244340b..ba67333 100644 --- a/docs/architecture_system_architecture.md +++ b/docs/architecture_system_architecture.md @@ -86,7 +86,14 @@ Auth policy: - Workstream verifies Flow-issued tokens through an `AuthVerifier` interface. - Production auth uses a Flow token verifier adapter. - Local development may use a mock verifier only outside production. -- Actor identity is based on stable token subject and issuer, not email. +- Actor identity is based on stable Flow token issuer and subject, not email. + Flow issuer plus subject remains the canonical portable identity anchor; + Workstream actor id is a local durable product reference derived from that + pair. +- Flow Identity token audience, scope, token id, client, space, and delegation + claims remain token/claim context. Workstream may preserve them in audit or + actor claim snapshots, but exact task, submission, review, payment, and + evidence access is still authorized locally by Workstream. - Workstream may keep local actor/profile records for workflow state, workflow eligibility, audit display, and reputation, but those records do not replace Flow as the auth source and do not grant route access. diff --git a/docs/glossary.md b/docs/glossary.md index 2544ff9..7852cc3 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -20,15 +20,19 @@ does not author or approve Workstream's machine-readable internal policy schema. The trusted per-request actor object resolved from a verified Flow token. It contains the current actor id, external subject, issuer, roles, claim snapshot, -auth source, and display metadata. Route authorization uses this current token -context, not persisted profile rows. +auth source, and display metadata. The Flow issuer plus subject is the canonical +portable identity anchor; Workstream's actor id is a local durable reference +derived from that pair. Route authorization uses this current token context, not +persisted profile rows. ## ActorIdentity Workstream's local durable identity record for a verified Flow actor. It is -keyed by the stable actor id derived from external issuer and subject. It +keyed by the stable Workstream actor id derived from the Flow issuer and +subject, while the issuer plus subject remains the canonical Flow identity. It supports audit display, profile linkage, assignment history, and later -reputation records. It is not Workstream-owned authentication. +reputation records. It is not Workstream-owned authentication, login, token +issuance, or global identity authority. ## ActorProfile diff --git a/docs/operations_roles_permissions.md b/docs/operations_roles_permissions.md index 44f59a6..379e878 100644 --- a/docs/operations_roles_permissions.md +++ b/docs/operations_roles_permissions.md @@ -26,6 +26,10 @@ owner is the external or internal source of project material or business terms. It may be recorded as scoped actor profile/contact metadata, but it does not approve Workstream machine-readable policies unless the verified token also carries an authorized Workstream role such as admin or project manager. +The token claim key for trusted relationship metadata is +`workstream_relationship_profiles`; v0.1 accepts only +`profile_type="project_owner"` with non-empty `scope_type` and `scope_id`, plus +optional object `profile_metadata`. Actor profile status is a workflow condition, not route permission. An `observed` profile only records that Workstream saw the actor through a verified diff --git a/docs/spec_chunk_2_auth_actor_boundary.md b/docs/spec_chunk_2_auth_actor_boundary.md index f0524ae..6e3dd9a 100644 --- a/docs/spec_chunk_2_auth_actor_boundary.md +++ b/docs/spec_chunk_2_auth_actor_boundary.md @@ -35,7 +35,7 @@ This chunk defines how FastAPI routes resolve the current actor from an external - The current actor is resolved by one FastAPI dependency. - Routers do not own permission logic. - Permission checks live in service/policy code. -- Actor identity uses stable Flow `subject + issuer`, not email. +- Actor identity uses stable Flow `issuer + subject`, not email. - Actor context includes role/claim context for later audit writes. - Dev/mock auth cannot run in production. - Dev/mock auth is explicit opt-in only for local, dev, development, or test environments. @@ -47,6 +47,12 @@ This chunk defines how FastAPI routes resolve the current actor from an external No database tables are introduced in this chunk. +Forward note: `WS-POL-001-11` later adds `ActorIdentity` and `ActorProfile` +tables. That does not change this chunk's authentication boundary: +`get_current_actor` remains pure token verification. Routes that need local +registry side effects use a separate registration dependency after actor +resolution. + The in-request actor context must expose: - `actor_id` @@ -57,6 +63,20 @@ The in-request actor context must expose: - `auth_source` - `is_dev_auth` +Forward note: `WS-POL-001-11` may read trusted scoped relationship metadata +from `claim_snapshot["workstream_relationship_profiles"]`. Each item must be an +object with: + +- `profile_type`: currently only `project_owner` +- `scope_type`: non-empty string such as `project` +- `scope_id`: non-empty string for that scope +- `profile_metadata`: optional object for non-authoritative contact/source + metadata + +These claims are token-derived relationship metadata. They may create observed +scoped `ActorProfile` rows for audit/display, but they do not authorize routes +or approve Workstream machine-readable policy. + ## API Impact Adds: @@ -65,6 +85,11 @@ Adds: This is a protected smoke endpoint used to prove actor resolution. It does not replace Flow login and does not create a session. +Forward note: after `WS-POL-001-11`, this endpoint also refreshes local +`ActorIdentity` and observed `ActorProfile` metadata through the registration +dependency. The response remains token-derived and no Workstream-owned auth +session is created. + ## Lifecycle Impact No Workstream task lifecycle transitions are implemented. @@ -84,7 +109,7 @@ The actor context created here becomes the input later services use for audit at - missing bearer token is rejected - invalid bearer token is rejected - valid dev token resolves actor id, external subject, external issuer, roles, auth source, and audit context -- actor id is derived from `external_subject + external_issuer`, not email +- actor id is derived from `external_issuer + external_subject`, not email - dev verifier cannot be created in production - Flow verifier adapter boundary rejects unsupported local verification - permission policy allows required roles diff --git a/docs/spec_chunk_4_task_queue_assignment.md b/docs/spec_chunk_4_task_queue_assignment.md index 494a0a7..2f8edf2 100644 --- a/docs/spec_chunk_4_task_queue_assignment.md +++ b/docs/spec_chunk_4_task_queue_assignment.md @@ -8,12 +8,12 @@ It covers: - task records under active projects - locked guide and policy context during task screening before release to `READY` -- worker profile records -- reviewer profile records +- worker actor profile records +- reviewer actor profile records - assignment records - lifecycle guards from `DRAFT` through `IN_PROGRESS` - audit events for task status changes -- skill tags on tasks and worker profiles +- skill tags on tasks and actor profiles ## Non-Scope @@ -48,10 +48,10 @@ Shared wiring: ## Data Model Impact -New tables: +Current tables after `WS-POL-001-11`: -- `worker_profiles` -- `reviewer_profiles` +- `actor_identities` +- `actor_profiles` - `workstream_tasks` - `task_assignments` - `audit_events` diff --git a/examples/terminal_benchmark/LOCAL_VALIDATION_NOTES.md b/examples/terminal_benchmark/LOCAL_VALIDATION_NOTES.md index 4676f9f..79b329d 100644 --- a/examples/terminal_benchmark/LOCAL_VALIDATION_NOTES.md +++ b/examples/terminal_benchmark/LOCAL_VALIDATION_NOTES.md @@ -60,11 +60,11 @@ Security observations: Product/ops observations: -- Demo worker profile bootstrap could be confused with canonical workflow. +- Worker profile setup needed to use the canonical profile workflow. - Locked wording used time-relative and external-tool-specific phrasing. -- Demo bootstrap is explicitly labeled as non-canonical v0.1 setup, output - includes `worker_profile_setup=demo_bootstrap_not_canonical_workflow`, and - guide/submission wording uses stable Terminal Benchmark fixture language. +- The drill now uses `POST /api/v1/workers/me/profile`, output includes + `worker_profile_setup=canonical_worker_profile_api`, and guide/submission + wording uses stable Terminal Benchmark fixture language. ## Validations diff --git a/examples/terminal_benchmark/terminal_benchmark_api_e2e.py b/examples/terminal_benchmark/terminal_benchmark_api_e2e.py index ebeaf69..2b91dfa 100644 --- a/examples/terminal_benchmark/terminal_benchmark_api_e2e.py +++ b/examples/terminal_benchmark/terminal_benchmark_api_e2e.py @@ -775,15 +775,12 @@ async def create_started_terminal_benchmark_task( manager_token, {"reason": f"Terminal Benchmark {suffix} release"}, ) - # Current v0.1 has no canonical worker-profile provisioning API. This demo - # route is used only to bootstrap the real task lifecycle drill. profile = await request_json( client, "POST", - "/api/v1/demo/worker-profile", + "/api/v1/workers/me/profile", worker_token, {"skill_tags": list(dict.fromkeys(fixture.metadata["tags"]))}, - 201, ) ensure(profile["external_subject"] == worker_subject, "worker profile subject drifted") ensure(profile["external_issuer"] == flow_issuer, "worker profile issuer drifted") @@ -1327,7 +1324,7 @@ async def exercise_terminal_benchmark_api(base_url: str, env: dict[str, str]) -> print("missing_static_guard=pre_submit_blocked_no_submission") print("low_quality_v1=needs_revision") print("fixed_low_quality_v2=review_pending") - print("worker_profile_setup=demo_bootstrap_not_canonical_workflow") + print("worker_profile_setup=canonical_worker_profile_api") async def main(env: dict[str, str]) -> None: From 50636b1baeba4b168cd0dc5ed2ba1154f570db1e Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 17:10:17 +0100 Subject: [PATCH 02/25] Record actor registry review evidence --- .agent-loop/LOOP_STATE.md | 17 +- .../STATUS.md | 13 +- .../WS-POL-001-11-internal-review-evidence.md | 111 +++++------ .../reviews/WS-POL-001-11-pr-trust-bundle.md | 179 ++++++++---------- 4 files changed, 146 insertions(+), 174 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 716299e..d9c65ca 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -4,17 +4,16 @@ - Active initiative: `WS-POL-001` - Submission Artifact Policy Foundation - Active planning chunk: `WS-POL-001-11` - Actor Identity And Profile Registry -- Active implementation chunk: none -- Branch: `codex/ws-pol-001-11-actor-identity-profile-contract` -- Status: `WS-POL-001-10` merged through PR #72. `WS-POL-001-11` has contract, - internal review evidence, and PR trust bundle prepared for human review. - Implementation is inactive until the contract is approved and the user gives - an explicit implementation start signal. +- Active implementation chunk: `WS-POL-001-11` - Actor Identity And Profile Registry +- Branch: `codex/ws-pol-001-11-actor-profile-registry-impl` +- Status: `WS-POL-001-10` merged through PR #72. `WS-POL-001-11` + implementation is committed, deterministic verification passed, required + internal reviewer tracks completed with no blocking findings, and evidence / + trust bundle are prepared for PR review. - Last merged implementation SHA: `cc78f2a` - Last merge commit: `1bbde47` -- Current gate: human review of `WS-POL-001-11` contract -- Next chunk: `WS-POL-001-11` is contract-only until human review approves the - implementation boundary +- Current gate: open PR and wait for external review / human checkpoint +- Next chunk: inactive until `WS-POL-001-11` is reviewed and merged ## Operating Rule diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/STATUS.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/STATUS.md index ca6d56c..d75ec7c 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/STATUS.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/STATUS.md @@ -5,14 +5,13 @@ `WS-POL-001-01`, `WS-POL-001-02`, `WS-POL-001-03`, `WS-POL-001-04`, `WS-POL-001-05`, `WS-POL-001-06`, `WS-POL-001-07`, `WS-POL-001-08`, `WS-POL-001-09`, and `WS-POL-001-10` are merged to `main`. `WS-POL-001-11` -contract evidence is ready for external review and human approval. -Implementation remains inactive until human review approves the actor -identity/profile implementation boundary and the user gives an explicit -implementation start signal. +implementation is committed on the active branch with deterministic +verification and internal reviewer evidence complete. The current gate is PR +review and human checkpoint. ## Active Chunk -`WS-POL-001-11` contract review. Implementation is inactive. +`WS-POL-001-11` implementation review. ## Chunk Status @@ -28,13 +27,13 @@ implementation start signal. | `WS-POL-001-08` | Merged | `codex/ws-pol-001-08-celery-project-setup` | 69 | Makes guide/source capture enqueue Celery pre-submit setup automatically: sufficiency first, blocked stops, draft submission artifact policy next; removes remaining construction-state compatibility surfaces. | | `WS-POL-001-09` | Merged | `codex/ws-pol-001-09-openai-agent-sdk-only` | 71 | Removes the production `local_fixture` project setup runtime and old runtime selector; keeps deterministic test behavior in explicit test-local fakes only. | | `WS-POL-001-10` | Merged | `codex/ws-pol-001-10-pre-submit-hardening` | 72 | Hardens duplicate guide-version conflicts, guide-create source snapshots, active-guide checker summaries, worker self-profile onboarding, and failed pre-submit audit evidence. | -| `WS-POL-001-11` | Internal review complete; implementation inactive | `codex/ws-pol-001-11-actor-identity-profile-contract` | 73 | Defines local Workstream actor identity and actor profile registries for verified Flow actors before the next live API drill. | +| `WS-POL-001-11` | Implementation ready for PR review | `codex/ws-pol-001-11-actor-profile-registry-impl` | - | Implements local Workstream actor identity and actor profile registries for verified Flow actors before the next live API drill. | ## Blockers | Blocker | Owner | Next action | |---|---|---| -| None | - | Human review decides whether to accept the contract before implementation starts. | +| None | - | Open PR, wait for external review, and keep the next chunk inactive until human checkpoint. | ## Follow-Ups diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md index 47d7559..9fa11b3 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md @@ -10,14 +10,11 @@ valid findings addressed: yes ## Reviewed Revision -Reviewed code SHA: b769b70c07d22f7a802f6d4219201e7bbd2a3ab0 +Reviewed code SHA: ef2cd187c4a1b4fc2b3cbb7d4d8d563ff9e50bb0 -Reviewed working diff digest before evidence files: -86a15c53c7e407b902094212a5c08143e9c91cf154544e38ddbfc5e9f123983d +Reviewed at: 2026-07-06T16:07:41Z -Reviewed at: 2026-07-06T11:57:16Z - -Reviewer run IDs: senior-engineering-review-019f3741-9fa2-72a2-93b2-58d9e043fd57, qa-test-review-019f3741-ac4e-7f83-b0c7-3acb174e0058, security-auth-review-019f3747-7bf3-7b63-b093-cf8ba17d1183, product-ops-review-019f3747-82e8-7f13-95c9-e8339f8f08e6, architecture-review-019f3741-befb-77a2-bd85-0c43766b2219, docs-review-019f3741-cdda-72c3-8cf6-4dc2a78260c1, reuse-dedup-review-019f3741-de89-7742-b300-74f54eb1558a, test-delta-review-019f3741-efdd-72e2-9b07-d8dfe2836a79 +Reviewer run IDs: senior-engineering-review-019f37c9-f47a-7fa2-ac9b-639db0d4943a, qa-test-review-019f3804-d7c9-7f40-9a55-abb3ab0ea152, security-auth-review-019f37ca-2035-7e93-b63d-e53c632ee23a, product-ops-review-019f37ca-2da9-7670-a0a1-9bb351b0ecf1, architecture-review-019f382c-b040-7ac3-a7f6-f26b17fbe533, docs-review-019f3805-095b-7490-b410-3b28688bc979, reuse-dedup-review-019f3805-369e-7050-ba48-b1aca7c8208c, test-delta-review-019f3805-5f52-73b1-9aba-6254ce075cd9 After the reviewed SHA, only evidence and status files changed. @@ -25,84 +22,74 @@ After the reviewed SHA, only evidence and status files changed. Scope: -- Defines the `WS-POL-001-11` actor identity and profile registry contract. -- Keeps Flow token verification as the route authorization source. -- Separates pure `get_current_actor` from the actor-registration boundary. -- Defines shared `ActorIdentity` and `ActorProfile` semantics for worker, - reviewer, admin, project manager, and project owner records. -- Requires old worker/reviewer profile stores to be migrated into the shared - actor profile model with no wrapper, shadow table, or dual-write authority. -- Clarifies profile statuses: `observed`, `active`, and `disabled`. -- Requires worker claim to remain worker-only and require both a verified - worker token role and active worker profile. -- Adds implementation scope for stale demo/script helper cleanup so the next - Terminal Benchmark drill uses `POST /api/v1/workers/me/profile`. - -No backend implementation code changed in this PR. +- Implements local `ActorIdentity` and shared `ActorProfile` persistence for verified Flow actors. +- Keeps `get_current_actor` as the pure Flow-token boundary and adds `get_registered_actor` for explicit registry side effects. +- Migrates legacy `worker_profiles` and `reviewer_profiles` into `actor_identities` and `actor_profiles`, then removes the old stores. +- Makes worker profile activation write `ActorProfile(profile_type="worker", status="active")` through the actor module. +- Makes task claim require both a verified `worker` token role and an active worker profile. +- Preserves active/disabled profile metadata during token observation refreshes. +- Keeps persisted profiles as workflow eligibility/audit records, never route permission authority. +- Rewires demo/scripts/examples from `/api/v1/demo/worker-profile` to `POST /api/v1/workers/me/profile`. +- Aligns docs with Flow issuer plus subject as the canonical identity anchor and Workstream actor id as a local durable reference. ## Reviewer Results | Reviewer | Result | Blocking findings | Notes | |---|---:|---|---| -| senior engineering | PASS | None | Confirmed final commit scope is implementable and contract file is tracked. | -| QA/test | PASS | None | Confirmed seeded migration backfill tests, stale helper scan, full task tests, and negative auth/profile tests are required. | -| security/auth | PASS | None | Confirmed persisted profiles do not grant route access, token roles remain authority, and spoofing/dev-auth requirements are present. | -| product/ops | PASS | None | Confirmed worker/reviewer/operator semantics, project-owner metadata boundary, and Terminal Benchmark drill route are clear. | -| architecture | PASS | None | Confirmed actor registration boundary, old profile authority removal, and scoped demo/script cleanup are feasible. | +| senior engineering | PASS AFTER FIXES | None | Fixed profile freshness updates, redundant identity upserts, and documented v0.1 audit ledger coupling. | +| QA/test | PASS | None | Confirmed migration/backfill/removal, registered auth boundary, profile eligibility, overposting protection, and demo/script rewiring. | +| security/auth | PASS AFTER FIXES | None | Confirmed token roles remain route authority; scoped docs stayed inside the chunk after reverting out-of-scope roadmap edits. | +| product/ops | PASS | None | Confirmed actor/profile workflow semantics after stale loop evidence was treated as pending final evidence work. | +| architecture | PASS WITH LOW RISKS | None | Confirmed Flow auth boundary, actor/profile non-auth semantics, worker claim gate, and documented v0.1 audit ledger coupling. | | CI integrity | N/A - with approved reason | N/A | No CI workflow, package script, dependency, lint, typecheck, or coverage configuration changed. | -| docs | PASS | None | Confirmed contract, chunk map, data model, system architecture, glossary, roles, and task spec are aligned. | -| reuse/dedup | PASS | None | Confirmed one actor module/profile authority and no wrapper/shadow/dual-write profile path in the contract. | -| test delta | PASS | None | Confirmed no tests were changed and the implementation contract requires the right regression coverage. | +| docs | PASS WITH LOW RISKS | None | Confirmed docs align after adding `workstream_relationship_profiles` schema, audit schema, demo cleanup, and issuer-plus-subject wording. | +| reuse/dedup | PASS | None | Confirmed `ActorService` is profile authority, audit writes reuse `TaskRepository.add_audit_event`, and old profile authority paths are removed. | +| test delta | PASS | None | Confirmed persisted overposting assertions, downgrade restore assertions, metadata negative assertions, and active/disabled metadata preservation coverage. | ## Valid Findings Addressed -- Committed the new chunk contract so the PR cannot omit the active contract. -- Clarified `project_owner` as scoped source/contact metadata, not a - route-authorizing Workstream role. -- Changed task claim permissions to worker-only and documented that - admin/project-manager intervention must use a separate audited override path. -- Added `is_dev_auth` to the `ActorIdentity` data model. -- Defined `observed`, `active`, and `disabled` profile statuses. -- Required observation refreshes to preserve existing `active` and `disabled` - status unless an explicit audited profile workflow changes status. -- Narrowed actor-registration side effects to routes updated in the chunk; - routes outside the chunk may continue using pure `get_current_actor`. -- Fixed system architecture wording so local actor/profile records are not - permission authority and registration dependency is separate from pure auth. -- Required seeded migration tests proving old worker/reviewer rows backfill - into `ActorIdentity` and `ActorProfile`, including profile type, status, - skill tags, scope, and old table removal. -- Added stale helper scope for `backend/scripts/week1_api_e2e.py`, - `backend/scripts/week2_api_e2e.py`, and - `examples/terminal_benchmark/terminal_benchmark_api_e2e.py`. -- Added a stale helper scan for old `WorkerProfile`/`ReviewerProfile` imports - and `/api/v1/demo/worker-profile` calls. -- Added `FAIL` to the contract's allowed reviewer results and stated that - Critical/High findings require `FAIL` until fixed. -- Added a forward note to the older Chunk 4 spec that `WS-POL-001-11` - supersedes separate worker/reviewer profile storage. +- Fixed active/disabled profile observation so token-role observation refreshes do not overwrite explicit workflow provenance metadata. +- Added regression coverage proving active and disabled worker profile metadata stays `worker_profile_api` after observation. +- Reused `TaskRepository.add_audit_event` for actor profile audit writes instead of adding a parallel audit persistence path. +- Removed the redundant task-service worker-profile facade; the route calls `ActorService.activate_worker_profile` directly. +- Added persisted-value overposting assertions for `POST /api/v1/workers/me/profile` and task claim so spoofed identity fields cannot write malicious registry rows. +- Added migration downgrade assertions proving legacy worker/reviewer rows are restored with actor id, subject, issuer, display/email, status, and skill tags. +- Added metadata negative assertions so old `worker_profiles`/`reviewer_profiles` metadata exports cannot silently return. +- Removed stale `/api/v1/demo/worker-profile` usage from backend scripts, Terminal Benchmark example, README, and the Week 1 demo UI. +- Removed stale `WORKSTREAM_ENABLE_DEMO_ROUTES=true` guidance where the Week 1 demo no longer needs demo routes. +- Documented exact `workstream_relationship_profiles` trusted claim schema and its non-authorizing behavior. +- Updated audit-event docs to match actual `actor_roles`, `from_status`, `to_status`, `is_dev_auth`, and `event_payload` fields. +- Aligned Flow Identity wording so docs consistently name Flow issuer plus subject as the canonical identity anchor. ## Commands Run ```bash +cd backend && .venv/bin/python -m ruff check app/api/deps/auth.py app/api/routes/auth.py app/modules/actors app/modules/tasks/models.py app/modules/tasks/repository.py app/modules/tasks/router.py app/modules/tasks/schemas.py app/modules/tasks/service.py tests/test_actors.py tests/test_alembic.py tests/test_auth.py tests/test_tasks.py +cd backend && .venv/bin/docstr-coverage app/api app/modules/actors app/modules/tasks --config .docstr.yaml python3 scripts/check_stale_workstream_wording.py python3 scripts/check_markdown_links.py -git diff --check HEAD~1..HEAD git diff --check +cd backend && .venv/bin/python -m pytest tests/test_alembic.py tests/test_actors.py tests/test_auth.py -q +cd backend && .venv/bin/python -m pytest tests/test_tasks.py -q +cd demos/week1_api_demo_ui && npm run build +rg -n 'worker_profile_setup=demo_bootstrap|demo worker profile|Activates demo worker profile|WORKSTREAM_ENABLE_DEMO_ROUTES|/api/v1/demo/worker-profile|WorkerProfile|ReviewerProfile' backend/scripts examples/terminal_benchmark backend/app/api/routes/demo.py backend/app/modules README.md demos/week1_api_demo_ui/src/App.tsx docs/spec_chunk_2_auth_actor_boundary.md docs/architecture_data_model.md docs/operations_roles_permissions.md ``` Results: +- Ruff: passed. +- Docstring coverage: 100.0%. - Stale wording scan: passed. -- Markdown link check: passed for 11 changed Markdown files. -- Diff whitespace checks: passed. +- Markdown link check: passed for 9 changed Markdown files. +- Diff whitespace check: passed. +- Migration/actor/auth tests: 35 passed in 518.35s. +- Task tests: 69 passed in 1184.65s. +- Week 1 demo UI build: passed. +- Stale demo/profile scan: no matches. - Local XLSX export: not present. ## Remaining Risks -- This PR is contract/process/docs only. The next implementation PR still needs - to prove the migration, actor service, profile route behavior, stale helper - removal, and live API drill with real API calls. -- Existing protected routers are not all migrated to actor registration in this - chunk. That is intentional; only routes touched in the implementation chunk - get the registration side effect. +- Actor profile audit writes use the existing task-owned audit ledger helper in v0.1. This keeps one audit source of truth for now, but a future shared audit module should extract the code boundary before actor/reputation work grows. +- Existing routes outside the chunk may continue using pure `get_current_actor` until deliberately migrated. This chunk only adds registration side effects to `/auth/me`, worker profile setup, and task claim paths touched here. +- The next Terminal Benchmark live API drill still needs to run through real HTTP calls against this implementation after PR review. diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md index a941fdc..0d68192 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md @@ -6,14 +6,11 @@ ## Goal -Approve the implementation contract for Workstream's local actor identity and -shared actor profile registry before coding the next backend chunk. +Implement Workstream's local actor identity and shared actor profile registry for verified Flow actors without turning Workstream into the auth provider or letting persisted profiles become route permissions. ## Human-Approved Intent -The user asked to avoid adding only a worker profile path and instead define -one shared actor/profile model for the current actor types: worker, reviewer, -admin, project manager, and project owner. +The user asked for one shared actor/profile model instead of separate worker, reviewer, admin, project-manager, and project-owner implementations. The verified Flow token remains the authority for route access. Workstream stores local actor/profile rows for workflow eligibility, audit, display, future routing, and later reputation linkage. Chunk contract: @@ -21,107 +18,105 @@ Chunk contract: ## What Changed -- Added a `WS-POL-001-11` chunk contract. -- Updated loop state, work queue, initiative status, and chunk map after PR #72 - merged. -- Updated data model, glossary, system architecture, roles/permissions, and the - older task queue spec to align with the actor identity/profile contract. -- Clarified that Flow token roles authorize routes and persisted profiles only - support workflow eligibility, audit, display, and later reputation context. -- Clarified `project_owner` as scoped source/contact metadata, not a - route-authorizing Workstream role. -- Required old worker/reviewer profile storage to migrate into shared - `ActorProfile` authority without wrappers, shadow tables, or dual writes. -- Added implementation scope for stale demo/script helper cleanup so live API - drills use `POST /api/v1/workers/me/profile`. +- Added `actor_identities` and `actor_profiles` with async SQLAlchemy models, repository, schemas, service, and Alembic migration. +- Backfilled legacy `worker_profiles` and `reviewer_profiles` into the shared actor registry and removed the old profile tables as profile authority. +- Kept `get_current_actor` pure and added `get_registered_actor` for explicit registry side effects. +- Updated `/auth/me`, `/workers/me/profile`, and task claim paths to register actor identity/profile metadata where needed. +- Made worker claim require current verified `worker` token role plus active `ActorProfile(profile_type="worker")`. +- Preserved active/disabled profile provenance during token observation refreshes. +- Retired stale demo worker-profile bootstrap paths and rewired scripts/examples/UI to `POST /api/v1/workers/me/profile`. +- Documented Flow issuer plus subject as the canonical identity anchor; Workstream actor id is a local durable reference. +- Documented trusted `workstream_relationship_profiles` claim shape for scoped project-owner metadata. ## Why It Changed -The live Terminal Benchmark drill showed worker profile setup was now real, but -the broader actor model still needed to be locked before implementation. Without -this contract, worker, reviewer, admin, and project-owner metadata could drift -into separate one-off models or, worse, become hidden permission authority. +The live Terminal Benchmark drill proved worker profile setup must be real. Doing only a worker-specific profile path would create the same problem again for reviewers, project managers, admins, project owners, audit, and later reputation. This chunk creates the shared actor/profile base before the next live API drill. ## Design Chosen -- `ActorIdentity` is the local durable record for a verified Flow actor. -- `ActorProfile` is the shared profile and workflow eligibility record. -- `get_current_actor` stays pure token verification. -- A separate registration dependency records actor/profile metadata when a - route deliberately needs that side effect. -- Route access is always derived from the current verified Flow token role. -- Profile status values are `observed`, `active`, and `disabled`. -- Task claim requires both verified `worker` token role and active worker - profile. +- `ActorIdentity` stores local durable identity rows for verified Flow actors. +- `ActorProfile` stores profile type, status, skill tags, scope, and non-authoritative metadata. +- `observed` records token-observed metadata for audit/display only. +- `active` is explicit workflow eligibility and still requires the matching current token role. +- `disabled` blocks workflow eligibility and is preserved by observation refresh. +- Route authorization still reads current `ActorContext.roles`, not database profile rows. +- Actor profile audit events use the existing Workstream `audit_events` ledger through `TaskRepository.add_audit_event`. ## Alternatives Rejected -- Separate worker/reviewer/admin profile tables: rejected because they create - duplicated profile authority. -- Persisted profiles as route permissions: rejected because Flow token claims - are the route authorization source. -- Automatic worker/reviewer eligibility from token observation: rejected - because eligibility must come from explicit profile workflows. -- Keeping old worker/reviewer profile wrappers: rejected because v0.1 is still - under construction and should not preserve stale compatibility layers. +- Separate profile tables per role: rejected because it duplicates profile authority. +- Persisted profiles as route permissions: rejected because Flow token claims are the route authorization source. +- Automatic worker/reviewer eligibility from token observation: rejected because eligibility must come from explicit profile workflows. +- Keeping old worker/reviewer compatibility stores: rejected because v0.1 is still under construction and should not preserve stale authority. +- A new audit table/module in this chunk: deferred because the current accepted boundary is one v0.1 audit ledger, with extraction documented for future actor/reputation expansion. ## Scope Control -No backend implementation code changed. No migrations, APIs, services, tests, -CI, frontend, payment, reputation, review lifecycle, checker runtime, agent -runtime, Celery behavior, blockchain behavior, or object storage behavior were -implemented. +Implemented only actor identity/profile registry, migration/backfill/removal, touched-route registration, worker profile activation, worker claim eligibility, tests, demo/script cleanup required by the migration, and documentation alignment. + +No Workstream-owned login/signup/session/password behavior was added. No post-submit, review, revision, payment, reputation, blockchain, object storage, agent runtime, or frontend product implementation was added. ## Product Behavior -- No runtime Workstream product behavior changed in this PR. -- Product review decisions remain only `accept`, `needs_revision`, and - `reject`. -- The PR only locks the implementation contract for the next backend chunk. +- Product review decisions remain only `accept`, `needs_revision`, and `reject`. +- Worker profile setup is now the canonical authenticated worker endpoint, not a demo bootstrap route. +- Task claim remains worker-only and now also requires active worker profile eligibility. +- Stored profile rows do not grant access without the matching current verified token role. ## Acceptance Criteria Proof -- Actor identity/profile contract exists and is tracked in git. -- Public docs now distinguish Flow auth, `ActorContext`, `ActorIdentity`, - `ActorProfile`, route authorization, and workflow eligibility. -- Permissions docs make task claim worker-only. -- Architecture docs no longer imply local actor/profile records grant route - permissions. -- The contract requires migration backfill tests, spoofing tests, persisted - profile privilege-escalation tests, status preservation tests, stale helper - scans, and full task test coverage. +- Migration creates actor registry tables and uniqueness constraints. +- Migration backfills worker/reviewer rows and removes old profile tables. +- Downgrade restores legacy rows with identity/profile data. +- SQLAlchemy metadata imports new actor models and has negative assertions for old profile exports. +- `/auth/me` registers identity/profile metadata without Workstream auth sessions. +- Repeated requests refresh identity/profile freshness without duplicate rows. +- Observed profiles are created for token roles but do not satisfy eligibility. +- Active/disabled profile statuses and metadata are preserved during observation. +- Worker profile activation requires worker token role and explicit API call. +- Claim requires worker token role plus active worker profile. +- Persisted active profiles without matching token roles cannot authorize worker/operator routes. +- Overposting tests prove spoofed identity fields do not alter persisted registry rows. +- Demo/scripts/examples no longer use `/api/v1/demo/worker-profile`. ## Tests/Checks Run ```bash +cd backend && .venv/bin/python -m ruff check app/api/deps/auth.py app/api/routes/auth.py app/modules/actors app/modules/tasks/models.py app/modules/tasks/repository.py app/modules/tasks/router.py app/modules/tasks/schemas.py app/modules/tasks/service.py tests/test_actors.py tests/test_alembic.py tests/test_auth.py tests/test_tasks.py +cd backend && .venv/bin/docstr-coverage app/api app/modules/actors app/modules/tasks --config .docstr.yaml python3 scripts/check_stale_workstream_wording.py python3 scripts/check_markdown_links.py -git diff --check HEAD~1..HEAD git diff --check +cd backend && .venv/bin/python -m pytest tests/test_alembic.py tests/test_actors.py tests/test_auth.py -q +cd backend && .venv/bin/python -m pytest tests/test_tasks.py -q +cd demos/week1_api_demo_ui && npm run build +rg -n 'worker_profile_setup=demo_bootstrap|demo worker profile|Activates demo worker profile|WORKSTREAM_ENABLE_DEMO_ROUTES|/api/v1/demo/worker-profile|WorkerProfile|ReviewerProfile' backend/scripts examples/terminal_benchmark backend/app/api/routes/demo.py backend/app/modules README.md demos/week1_api_demo_ui/src/App.tsx docs/spec_chunk_2_auth_actor_boundary.md docs/architecture_data_model.md docs/operations_roles_permissions.md ``` Result summary: +- Ruff: passed. +- Docstring coverage: 100.0%. - Stale wording scan: passed. -- Markdown link check: passed for 11 changed Markdown files. -- Diff whitespace checks: passed. -- Local XLSX export: not present. +- Markdown link check: passed for 9 changed Markdown files. +- Diff whitespace check: passed. +- Migration/actor/auth tests: 35 passed in 518.35s. +- Task tests: 69 passed in 1184.65s. +- Week 1 demo UI build: passed. +- Stale demo/profile scan: no matches. ## Test Delta -- Tests added: none. -- Tests modified: none. +- Tests added: `backend/tests/test_actors.py`. +- Tests expanded: Alembic backfill/downgrade/uniqueness, auth registration, task metadata, token-role authorization, overposting, active-profile eligibility, and stale route removal. - Tests removed/skipped: none. -- The next implementation contract explicitly requires the relevant tests. ## CI Integrity - Coverage threshold unchanged. -- Lint configuration unchanged. -- Typecheck configuration unchanged. +- Lint/typecheck configuration unchanged. - No workflow weakening. - No package script weakening. -- No new GitHub Actions. - No dependency changes. ## Reviewer Results @@ -130,56 +125,48 @@ Internal review evidence: - `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md` -Reviewed code SHA: `b769b70c07d22f7a802f6d4219201e7bbd2a3ab0` - -Reviewed diff digest before evidence files: -`86a15c53c7e407b902094212a5c08143e9c91cf154544e38ddbfc5e9f123983d` +Reviewed code SHA: `ef2cd187c4a1b4fc2b3cbb7d4d8d563ff9e50bb0` Reviewer run IDs: see `WS-POL-001-11-internal-review-evidence.md`. | Reviewer | Result | Blocking findings | Notes | |---|---:|---|---| -| senior engineering | PASS | None | Final scope and implementability confirmed. | -| QA/test | PASS | None | Required migration, profile, stale-helper, and task-test coverage confirmed. | -| security/auth | PASS | None | Token authority and profile non-permission boundary confirmed. | -| product/ops | PASS | None | Worker/reviewer/operator semantics confirmed. | -| architecture | PASS | None | Module boundary and demo/script cleanup scope confirmed. | +| senior engineering | PASS AFTER FIXES | None | Fixed profile freshness, identity upsert churn, and documented audit ledger coupling. | +| QA/test | PASS | None | Confirmed actor registry behavior, migration, auth boundary, eligibility gates, and demo rewiring. | +| security/auth | PASS AFTER FIXES | None | Confirmed token authority and fail-closed scope after fixes. | +| product/ops | PASS | None | Confirmed actor/profile workflow semantics. | +| architecture | PASS WITH LOW RISKS | None | Confirmed auth/profile boundaries; future shared audit module noted as follow-up. | | CI integrity | N/A - with approved reason | N/A | No CI/workflow/package/dependency/config changes. | -| docs | PASS | None | Standing docs and chunk map align with the contract. | -| reuse/dedup | PASS | None | Single actor module/profile authority confirmed. | -| test delta | PASS | None | No tests changed; planned coverage is explicit. | +| docs | PASS WITH LOW RISKS | None | Confirmed docs alignment after final issuer-plus-subject wording. | +| reuse/dedup | PASS | None | Confirmed single actor/profile authority and audit helper reuse. | +| test delta | PASS | None | Confirmed tests were strengthened and not weakened. | ## External Review -External review has not run yet. CodeRabbit and GitHub checks should run after -the PR is opened. +External review has not run yet. CodeRabbit and GitHub checks should run after the PR is opened. ## Remaining Risks -- The next PR must implement and prove the contract with real backend code and - tests. -- Existing routes outside the next chunk may continue using pure - `get_current_actor` until they are deliberately migrated. -- The next Terminal Benchmark drill must use the real worker profile endpoint, - not `/api/v1/demo/worker-profile`. +- Actor profile audit persistence currently imports the existing task audit repository. This keeps one v0.1 audit ledger but should be extracted to a shared audit module before actor/reputation work grows. +- Existing routes outside this chunk may continue using pure `get_current_actor` until deliberately migrated. +- The next Terminal Benchmark live API drill still needs to run against this implementation through real HTTP calls after merge. ## Follow-Up Work -- Implement `WS-POL-001-11` after human approval. -- Run the Terminal Benchmark live API drill with real HTTP requests after the - actor/profile registry implementation merges. +- Open PR and wait for CodeRabbit/GitHub checks. +- Address external review in a separate external-review response file if needed. +- After merge, rerun the Terminal Benchmark live API drill using the canonical worker profile endpoint. ## Human Review Focus Please inspect: -- The route authority boundary: Flow token role first, profile eligibility - second. -- The old worker/reviewer profile migration and no-compatibility stance. -- The `project_owner` wording as scoped source/contact metadata. -- The stale demo/script cleanup scope for the next live API drill. +- Flow auth boundary: token role first, profile eligibility second. +- Migration/backfill/removal of old worker/reviewer profile stores. +- `ActorProfile` status semantics and preservation of explicit profile metadata. +- Overposting protections around registry writes. +- Demo/script cleanup from demo bootstrap to canonical worker profile API. ## Human Merge Ownership -Only the user can approve and merge this PR. Codex must not merge it without -explicit user approval for that specific PR. +Only the user can approve and merge this PR. Codex must not merge it without explicit user approval for that specific PR. From 912a1bef3ce7065e9563a03a440b24efe6af3f89 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 17:15:36 +0100 Subject: [PATCH 03/25] Fix dry run uuid import --- backend/scripts/week1_dry_run.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/scripts/week1_dry_run.py b/backend/scripts/week1_dry_run.py index 1317a51..17f9b7f 100644 --- a/backend/scripts/week1_dry_run.py +++ b/backend/scripts/week1_dry_run.py @@ -6,6 +6,7 @@ import hashlib import os from pathlib import Path +from uuid import uuid4 from alembic import command from alembic.config import Config From 5b2ab2e107589cdb5854897c2f442ca04d2bb868 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 17:18:39 +0100 Subject: [PATCH 04/25] Update actor registry review evidence for CI fix --- .../reviews/WS-POL-001-11-internal-review-evidence.md | 9 +++++---- .../reviews/WS-POL-001-11-pr-trust-bundle.md | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md index 9fa11b3..b42dcb6 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md @@ -10,11 +10,11 @@ valid findings addressed: yes ## Reviewed Revision -Reviewed code SHA: ef2cd187c4a1b4fc2b3cbb7d4d8d563ff9e50bb0 +Reviewed code SHA: 912a1bef3ce7065e9563a03a440b24efe6af3f89 -Reviewed at: 2026-07-06T16:07:41Z +Reviewed at: 2026-07-06T16:17:45Z -Reviewer run IDs: senior-engineering-review-019f37c9-f47a-7fa2-ac9b-639db0d4943a, qa-test-review-019f3804-d7c9-7f40-9a55-abb3ab0ea152, security-auth-review-019f37ca-2035-7e93-b63d-e53c632ee23a, product-ops-review-019f37ca-2da9-7670-a0a1-9bb351b0ecf1, architecture-review-019f382c-b040-7ac3-a7f6-f26b17fbe533, docs-review-019f3805-095b-7490-b410-3b28688bc979, reuse-dedup-review-019f3805-369e-7050-ba48-b1aca7c8208c, test-delta-review-019f3805-5f52-73b1-9aba-6254ce075cd9 +Reviewer run IDs: senior-engineering-review-019f37c9-f47a-7fa2-ac9b-639db0d4943a, qa-test-review-019f3804-d7c9-7f40-9a55-abb3ab0ea152, security-auth-review-019f37ca-2035-7e93-b63d-e53c632ee23a, product-ops-review-019f37ca-2da9-7670-a0a1-9bb351b0ecf1, architecture-review-019f382c-b040-7ac3-a7f6-f26b17fbe533, docs-review-019f3805-095b-7490-b410-3b28688bc979, reuse-dedup-review-019f3805-369e-7050-ba48-b1aca7c8208c, test-delta-review-019f3805-5f52-73b1-9aba-6254ce075cd9, ci-integrity-review-019f3836-8680-7f90-be04-b79fa169f57b After the reviewed SHA, only evidence and status files changed. @@ -41,7 +41,7 @@ Scope: | security/auth | PASS AFTER FIXES | None | Confirmed token roles remain route authority; scoped docs stayed inside the chunk after reverting out-of-scope roadmap edits. | | product/ops | PASS | None | Confirmed actor/profile workflow semantics after stale loop evidence was treated as pending final evidence work. | | architecture | PASS WITH LOW RISKS | None | Confirmed Flow auth boundary, actor/profile non-auth semantics, worker claim gate, and documented v0.1 audit ledger coupling. | -| CI integrity | N/A - with approved reason | N/A | No CI workflow, package script, dependency, lint, typecheck, or coverage configuration changed. | +| CI integrity | PASS | None | Confirmed the post-PR lint fix only adds the missing `uuid4` import and does not weaken CI, lint, tests, typecheck, coverage, workflows, or package scripts. | | docs | PASS WITH LOW RISKS | None | Confirmed docs align after adding `workstream_relationship_profiles` schema, audit schema, demo cleanup, and issuer-plus-subject wording. | | reuse/dedup | PASS | None | Confirmed `ActorService` is profile authority, audit writes reuse `TaskRepository.add_audit_event`, and old profile authority paths are removed. | | test delta | PASS | None | Confirmed persisted overposting assertions, downgrade restore assertions, metadata negative assertions, and active/disabled metadata preservation coverage. | @@ -60,6 +60,7 @@ Scope: - Documented exact `workstream_relationship_profiles` trusted claim schema and its non-authorizing behavior. - Updated audit-event docs to match actual `actor_roles`, `from_status`, `to_status`, `is_dev_auth`, and `event_payload` fields. - Aligned Flow Identity wording so docs consistently name Flow issuer plus subject as the canonical identity anchor. +- Fixed the GitHub Backend/Lint failure by importing `uuid4` in the Week 1 dry-run script. ## Commands Run diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md index 0d68192..857012a 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md @@ -125,7 +125,7 @@ Internal review evidence: - `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md` -Reviewed code SHA: `ef2cd187c4a1b4fc2b3cbb7d4d8d563ff9e50bb0` +Reviewed code SHA: `912a1bef3ce7065e9563a03a440b24efe6af3f89` Reviewer run IDs: see `WS-POL-001-11-internal-review-evidence.md`. @@ -136,7 +136,7 @@ Reviewer run IDs: see `WS-POL-001-11-internal-review-evidence.md`. | security/auth | PASS AFTER FIXES | None | Confirmed token authority and fail-closed scope after fixes. | | product/ops | PASS | None | Confirmed actor/profile workflow semantics. | | architecture | PASS WITH LOW RISKS | None | Confirmed auth/profile boundaries; future shared audit module noted as follow-up. | -| CI integrity | N/A - with approved reason | N/A | No CI/workflow/package/dependency/config changes. | +| CI integrity | PASS | None | Confirmed the post-PR lint fix only imports `uuid4` and does not weaken CI or tests. | | docs | PASS WITH LOW RISKS | None | Confirmed docs alignment after final issuer-plus-subject wording. | | reuse/dedup | PASS | None | Confirmed single actor/profile authority and audit helper reuse. | | test delta | PASS | None | Confirmed tests were strengthened and not weakened. | From fa193a5d6779ff499261e3cc1214b73366b3cd18 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 18:42:49 +0100 Subject: [PATCH 05/25] Harden actor registry and remove stale demo surfaces --- ...-001-11-actor-identity-profile-registry.md | 85 +- .github/workflows/backend.yml | 4 +- .github/workflows/week1-api-demo-ui.yml | 30 - README.md | 52 - .../0012_actor_identity_profile_registry.py | 181 +- backend/app/api/deps/auth.py | 15 +- backend/app/api/router.py | 2 - backend/app/api/routes/demo.py | 25 - backend/app/core/config.py | 2 +- backend/app/modules/actors/models.py | 7 +- backend/app/modules/actors/repository.py | 18 +- backend/app/modules/actors/service.py | 134 +- backend/app/modules/tasks/router.py | 6 +- .../{week1_api_e2e.py => api_contract_e2e.py} | 42 +- backend/scripts/week1_dry_run.py | 322 --- backend/scripts/week2_api_e2e.py | 6 +- backend/tests/test_actors.py | 36 +- backend/tests/test_alembic.py | 234 +-- demos/week1_api_demo_ui/index.html | 12 - demos/week1_api_demo_ui/package-lock.json | 1721 ----------------- demos/week1_api_demo_ui/package.json | 23 - demos/week1_api_demo_ui/src/App.tsx | 698 ------- demos/week1_api_demo_ui/src/api.ts | 205 -- demos/week1_api_demo_ui/src/main.tsx | 11 - demos/week1_api_demo_ui/src/styles.css | 497 ----- demos/week1_api_demo_ui/tsconfig.app.json | 21 - demos/week1_api_demo_ui/tsconfig.json | 6 - demos/week1_api_demo_ui/vite.config.ts | 13 - docs/architecture_data_model.md | 21 +- docs/glossary.md | 16 +- docs/operations_roles_permissions.md | 48 +- docs/roadmap_status.md | 10 +- .../terminal_benchmark_api_e2e.py | 6 +- scripts/check_internal_review_evidence.py | 19 +- scripts/test_agent_gates.py | 10 - 35 files changed, 342 insertions(+), 4196 deletions(-) delete mode 100644 .github/workflows/week1-api-demo-ui.yml delete mode 100644 backend/app/api/routes/demo.py rename backend/scripts/{week1_api_e2e.py => api_contract_e2e.py} (97%) delete mode 100644 backend/scripts/week1_dry_run.py delete mode 100644 demos/week1_api_demo_ui/index.html delete mode 100644 demos/week1_api_demo_ui/package-lock.json delete mode 100644 demos/week1_api_demo_ui/package.json delete mode 100644 demos/week1_api_demo_ui/src/App.tsx delete mode 100644 demos/week1_api_demo_ui/src/api.ts delete mode 100644 demos/week1_api_demo_ui/src/main.tsx delete mode 100644 demos/week1_api_demo_ui/src/styles.css delete mode 100644 demos/week1_api_demo_ui/tsconfig.app.json delete mode 100644 demos/week1_api_demo_ui/tsconfig.json delete mode 100644 demos/week1_api_demo_ui/vite.config.ts diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md index 0d267ba..1e33fb0 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md @@ -32,13 +32,16 @@ Flow token verification `ActorIdentity` and `ActorProfile` are not authentication. Flow remains the auth provider. Workstream still does not own login, signup, password reset, password -storage, primary auth sessions, or role authority. Route access is still -decided from the verified token for each request. +storage, or primary auth sessions. Workstream owns product authorization and +the future role-assignment API keyed by issuer plus subject; this chunk only +adds the shared identity/profile registry needed before that layer. In the +v0.1 bootstrap, route access may still use trusted roles from the verified +`ActorContext` until Workstream-owned role assignment records are implemented. Persisted `ActorProfile.profile_type` is metadata, eligibility, audit context, and later routing/reputation context. It may be required as an additional -workflow condition, but it must never grant access without the matching verified -token role. +workflow condition, but it is not the canonical product role-assignment table +and must never grant route access by itself. ## Approved plan reference @@ -61,10 +64,11 @@ P1 ```text backend/alembic/versions/*_actor_identity_profile_registry.py +.github/workflows/backend.yml backend/app/api/router.py backend/app/api/deps/auth.py -backend/app/api/routes/demo.py backend/app/api/routes/auth.py +backend/app/core/config.py backend/app/db/models.py backend/app/modules/actors/__init__.py backend/app/modules/actors/models.py @@ -80,20 +84,21 @@ backend/tests/test_actors.py backend/tests/test_alembic.py backend/tests/test_auth.py backend/tests/test_tasks.py -backend/scripts/week1_dry_run.py -backend/scripts/week1_api_e2e.py +backend/scripts/api_contract_e2e.py backend/scripts/week2_api_e2e.py examples/terminal_benchmark/terminal_benchmark_api_e2e.py examples/terminal_benchmark/LOCAL_VALIDATION_NOTES.md README.md -demos/week1_api_demo_ui/src/App.tsx docs/architecture_data_model.md docs/architecture_lockdown.md docs/architecture_system_architecture.md docs/glossary.md docs/operations_roles_permissions.md +docs/roadmap_status.md docs/spec_chunk_2_auth_actor_boundary.md docs/spec_chunk_4_task_queue_assignment.md +scripts/check_internal_review_evidence.py +scripts/test_agent_gates.py .agent-loop/LOOP_STATE.md .agent-loop/WORK_QUEUE.md .agent-loop/REVIEW_LOG.md @@ -110,6 +115,7 @@ Workstream-owned login, signup, password reset, password storage, primary auth s Flow token verification provider replacement roles or permissions sourced from ActorIdentity instead of the verified token roles or permissions sourced from ActorProfile instead of the verified token +canonical product roles sourced from Identity Issuer token claims instead of Workstream-owned role-assignment records once that layer exists automatic task claiming, reviewing, payment, or project access from profiles alone worker/reviewer routing changes beyond moving profile storage to ActorProfile and preserving current worker-profile API behavior task lifecycle, submission, checker, review, revision, payment, or reputation behavior changes @@ -117,12 +123,12 @@ agent runtime, project setup pipeline, Celery, storage, frontend, or demo featur blockchain, ERC-8004, ERC-8183, settlement, marketplace, or external source adapters ``` -Demo/backend script/example edits are allowed only to remove, retire, or rewire -stale `WorkerProfile`/`ReviewerProfile` imports and stale -`/api/v1/demo/worker-profile` calls that would break application import or -local evidence scripts after the shared actor profile migration. The Week 1 demo -README/UI may be updated only to point worker profile setup at -`POST /api/v1/workers/me/profile`. +Backend script/example edits are allowed only to remove, retire, or rewire +stale `WorkerProfile`/`ReviewerProfile` imports and stale demo/profile calls +that would break application import or local evidence scripts after the shared +actor profile migration. Stale demo routes, demo workflow files, and obsolete +Week 1 script entry points should be removed rather than kept as compatibility +surfaces. ## Expected design @@ -256,8 +262,11 @@ This must be fail-closed: ### Route Access And Workflow Eligibility -Route authorization must always check verified token roles from `ActorContext`. -Profile checks are additional workflow eligibility checks. +Route authorization currently checks trusted request roles from `ActorContext` +as the v0.1 bootstrap path. The Identity Issuer is not the canonical source of +Workstream product roles; the later Workstream role-assignment API must own that +authorization state keyed to `ActorIdentity`. Profile checks are additional +workflow eligibility checks, not route permissions. Examples: @@ -282,14 +291,12 @@ Reviewer action later: This preserves the security boundary and still gives Workstream one shared profile implementation. -### Compatibility Boundary +### Obsolete Surface Removal Boundary The implementation path is fixed: - create `actor_identities` - create `actor_profiles` -- backfill actor/profile rows from existing `worker_profiles` and - `reviewer_profiles` rows when those rows exist - remove the separate `worker_profiles` and `reviewer_profiles` tables as independent profile stores - remove old worker/reviewer profile ORM models and repository authority paths; @@ -304,26 +311,27 @@ tables, views, or dual-write paths in v0.1. If the implementation discovers a hard migration blocker, stop and return for human review instead of keeping two profile sources of truth. +This repository is still in active buildout. Obsolete experimental profile data +does not receive a compatibility backfill. The migration moves the schema to the +current contract and drops the old profile tables. + ## Acceptance criteria - [ ] Alembic creates `actor_identities` with uniqueness on `actor_id` and `external_issuer + external_subject`. - [ ] Alembic creates `actor_profiles` with uniqueness on `actor_id`, `profile_type`, `scope_type`, and `scope_id`. -- [ ] Alembic backfills existing worker/reviewer profile rows into - `actor_identities` and `actor_profiles`, then removes the separate - `worker_profiles` and `reviewer_profiles` tables as independent profile - stores. +- [ ] Alembic removes the separate `worker_profiles` and `reviewer_profiles` + tables as independent profile stores without keeping compatibility + backfill or dual-write behavior. - [ ] Worker/reviewer ORM models and repositories stop owning profile state; services use the actor module as the profile authority. - [ ] SQLAlchemy metadata imports the new actor models so Alembic does not drift. - [ ] Migration tests prove the new tables, unique constraints, metadata imports, and downgrade/upgrade path expected by the repo. -- [ ] Seeded migration tests prove existing `worker_profiles` and - `reviewer_profiles` rows are backfilled into `ActorIdentity` and - `ActorProfile` with expected `profile_type`, `status`, `skill_tags`, - `scope_type`, and `scope_id`, and that old profile tables are removed. +- [ ] Migration tests prove old profile tables are absent from the current + schema and no legacy profile authority remains. - [ ] API requests updated in this chunk create or refresh `ActorIdentity` from trusted `ActorContext` through the explicit actor-registration dependency. Routes outside this chunk may continue using pure @@ -376,21 +384,18 @@ profile sources of truth. - [ ] Docs clearly distinguish Flow auth, `ActorContext`, `ActorIdentity`, `ActorProfile`, route authorization, and workflow eligibility. - [ ] The next Terminal Benchmark live API drill must use - `POST /api/v1/workers/me/profile` for worker profile setup. It must not - use `/api/v1/demo/worker-profile`. -- [ ] Stale demo/backend evidence helpers no longer import or write old + `POST /api/v1/workers/me/profile` for worker profile setup. +- [ ] Stale backend evidence helpers no longer import or write old `WorkerProfile` or `ReviewerProfile` models after those stores are - removed. They are either rewired to `ActorProfile` or explicitly retired. -- [ ] `backend/scripts/week1_api_e2e.py`, + removed. They are rewired to `ActorProfile` or explicitly retired. +- [ ] `backend/scripts/api_contract_e2e.py`, `backend/scripts/week2_api_e2e.py`, and - `examples/terminal_benchmark/terminal_benchmark_api_e2e.py` no longer - call `/api/v1/demo/worker-profile`; they either use + `examples/terminal_benchmark/terminal_benchmark_api_e2e.py` use `POST /api/v1/workers/me/profile` or are explicitly retired. -- [ ] Terminal Benchmark validation notes no longer describe worker profile - setup as demo bootstrap; they identify the canonical worker profile API. -- [ ] Week 1 demo README/UI no longer advertise or call - `/api/v1/demo/worker-profile`; the demo uses - `POST /api/v1/workers/me/profile`. +- [ ] Terminal Benchmark validation notes identify the canonical worker profile + API and do not describe worker profile setup as an obsolete bootstrap path. +- [ ] The obsolete Week 1 demo UI, local demo route, and deleted script entry + points are not kept as compatibility surfaces. ## Verification commands @@ -399,7 +404,7 @@ cd backend && .venv/bin/python -m ruff check app/api/deps/auth.py app/api/routes cd backend && .venv/bin/python -m pytest tests/test_alembic.py tests/test_actors.py tests/test_auth.py -q cd backend && .venv/bin/python -m pytest tests/test_tasks.py -q cd backend && .venv/bin/docstr-coverage app/api app/modules/actors app/modules/tasks --config .docstr.yaml -! rg -n 'WorkerProfile|ReviewerProfile|/api/v1/demo/worker-profile' backend/scripts examples/terminal_benchmark backend/app/api/routes/demo.py backend/app/modules README.md demos/week1_api_demo_ui/src/App.tsx +! rg -n 'WorkerProfile|ReviewerProfile|/api/v1/demo/worker-profile|week1_api_e2e|week1_dry_run|week1_api_demo_ui|WORKSTREAM_ENABLE_DEMO_ROUTES' backend/scripts examples/terminal_benchmark backend/app README.md docs/roadmap_status.md scripts python3 scripts/check_stale_workstream_wording.py python3 scripts/check_markdown_links.py git diff --check diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index db70301..338b717 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -59,8 +59,8 @@ jobs: working-directory: backend run: pytest -q - - name: Week 1 real API e2e + - name: API contract real API e2e working-directory: backend env: WORKSTREAM_DATABASE_URL: postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test - run: python scripts/week1_api_e2e.py + run: python scripts/api_contract_e2e.py diff --git a/.github/workflows/week1-api-demo-ui.yml b/.github/workflows/week1-api-demo-ui.yml deleted file mode 100644 index 4f275e3..0000000 --- a/.github/workflows/week1-api-demo-ui.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Week 1 API Demo UI - -on: - pull_request: - push: - branches: - - main - -jobs: - build-demo-ui: - 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: demos/week1_api_demo_ui/package-lock.json - - - name: Install demo UI - working-directory: demos/week1_api_demo_ui - run: npm ci - - - name: Build demo UI - working-directory: demos/week1_api_demo_ui - run: npm run build diff --git a/README.md b/README.md index a1d64bf..cf0589d 100644 --- a/README.md +++ b/README.md @@ -204,58 +204,6 @@ WORKSTREAM_CELERY_BROKER_URL=redis://localhost:6379/0 \ .venv/bin/celery -A app.workers.celery_app.celery_app worker --loglevel=INFO ``` -## Week 1 API Demo UI - -The Week 1 API demo UI lives in `demos/week1_api_demo_ui/`. It is a temporary walkthrough client for the Week 1 backend APIs, not the canonical Workstream frontend implementation. 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_PROJECT_SETUP_PIPELINE_AUTOSTART=false \ -WORKSTREAM_CELERY_BROKER_URL=redis://localhost:6379/0 \ -.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_PROJECT_SETUP_PIPELINE_AUTOSTART=false \ -WORKSTREAM_CELERY_BROKER_URL=redis://localhost:6379/0 \ -.venv/bin/python -m uvicorn app.main:create_app --factory --host 127.0.0.1 --port 8000 -``` - -The Week 1 demo keeps project setup autostart disabled because it is a temporary -backend walkthrough, not the current automatic setup-agent proof path. Use the -project setup worker command above when testing guide sufficiency and policy -derivation. - -Start the demo UI: - -```bash -cd demos/week1_api_demo_ui -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. Worker profile setup uses `POST /api/v1/workers/me/profile` -with a worker token; the old local demo worker-profile route is retired. - ## Day-30 Success Standard By day 30, Workstream runs a real internal task cycle with real people: diff --git a/backend/alembic/versions/0012_actor_identity_profile_registry.py b/backend/alembic/versions/0012_actor_identity_profile_registry.py index 0182871..c69adbf 100644 --- a/backend/alembic/versions/0012_actor_identity_profile_registry.py +++ b/backend/alembic/versions/0012_actor_identity_profile_registry.py @@ -7,11 +7,8 @@ from __future__ import annotations -from uuid import uuid4 - from alembic import op import sqlalchemy as sa -from sqlalchemy.dialects import postgresql revision = "0012_actor_identity_profiles" down_revision = "0011_task_artifact_cleanup" @@ -19,37 +16,8 @@ depends_on = None -actor_identities_table = sa.table( - "actor_identities", - sa.column("actor_id", sa.String), - sa.column("external_subject", sa.String), - sa.column("external_issuer", sa.String), - sa.column("display_name", sa.String), - sa.column("email", sa.String), - sa.column("last_seen_roles", sa.JSON), - sa.column("last_claim_snapshot", sa.JSON), - sa.column("auth_source", sa.String), - sa.column("is_dev_auth", sa.Boolean), - sa.column("last_seen_at", sa.DateTime), - sa.column("updated_at", sa.DateTime), -) - -actor_profiles_table = sa.table( - "actor_profiles", - sa.column("id", sa.String), - sa.column("actor_id", sa.String), - sa.column("profile_type", sa.String), - sa.column("status", sa.String), - sa.column("skill_tags", sa.JSON), - sa.column("scope_type", sa.String), - sa.column("scope_id", sa.String), - sa.column("profile_metadata", sa.JSON), - sa.column("updated_at", sa.DateTime), -) - - def upgrade() -> None: - """Create actor registry tables and migrate old profile stores.""" + """Create actor registry tables and remove obsolete profile stores.""" op.create_table( "actor_identities", sa.Column("actor_id", sa.String(length=100), nullable=False), @@ -109,10 +77,6 @@ def upgrade() -> None: op.create_index(op.f("ix_actor_profiles_profile_type"), "actor_profiles", ["profile_type"], unique=False) op.create_index(op.f("ix_actor_profiles_status"), "actor_profiles", ["status"], unique=False) - connection = op.get_bind() - _backfill_legacy_profiles(connection, "worker_profiles", "worker") - _backfill_legacy_profiles(connection, "reviewer_profiles", "reviewer") - op.drop_index(op.f("ix_reviewer_profiles_status"), table_name="reviewer_profiles") op.drop_table("reviewer_profiles") op.drop_index(op.f("ix_worker_profiles_status"), table_name="worker_profiles") @@ -120,7 +84,7 @@ def upgrade() -> None: def downgrade() -> None: - """Restore legacy profile tables from actor profiles, then drop registry tables.""" + """Restore prior schema shape without preserving obsolete profile data.""" op.create_table( "worker_profiles", sa.Column("id", sa.String(length=36), nullable=False), @@ -154,149 +118,8 @@ def downgrade() -> None: ) op.create_index(op.f("ix_reviewer_profiles_status"), "reviewer_profiles", ["status"], unique=False) - connection = op.get_bind() - _restore_legacy_profiles(connection, "worker_profiles", "worker") - _restore_legacy_profiles(connection, "reviewer_profiles", "reviewer") - op.drop_index(op.f("ix_actor_profiles_status"), table_name="actor_profiles") op.drop_index(op.f("ix_actor_profiles_profile_type"), table_name="actor_profiles") op.drop_index(op.f("ix_actor_profiles_actor_id"), table_name="actor_profiles") op.drop_table("actor_profiles") op.drop_table("actor_identities") - - -def _backfill_legacy_profiles(connection, table_name: str, profile_type: str) -> None: - """Backfill one legacy profile table into actor identities and profiles.""" - rows = ( - connection.execute( - sa.text( - f""" - select - id, - actor_id, - external_subject, - external_issuer, - display_name, - email, - skill_tags, - status, - created_at, - updated_at - from {table_name} - """ - ) - ) - .mappings() - .all() - ) - for row in rows: - roles = [profile_type] - connection.execute( - postgresql.insert(actor_identities_table) - .values( - actor_id=row["actor_id"], - external_subject=row["external_subject"], - external_issuer=row["external_issuer"], - display_name=row["display_name"], - email=row["email"], - last_seen_roles=roles, - last_claim_snapshot={"legacy_profile_backfill": table_name}, - auth_source="legacy_profile_backfill", - is_dev_auth=False, - ) - .on_conflict_do_update( - index_elements=["actor_id"], - set_={ - "external_subject": row["external_subject"], - "external_issuer": row["external_issuer"], - "display_name": row["display_name"], - "email": row["email"], - "last_seen_roles": roles, - "last_claim_snapshot": {"legacy_profile_backfill": table_name}, - "auth_source": "legacy_profile_backfill", - "is_dev_auth": False, - "last_seen_at": sa.func.now(), - "updated_at": sa.func.now(), - }, - ) - ) - connection.execute( - postgresql.insert(actor_profiles_table) - .values( - id=row["id"], - actor_id=row["actor_id"], - profile_type=profile_type, - status=row["status"], - skill_tags=row["skill_tags"] or [], - scope_type="global", - scope_id="global", - profile_metadata={"legacy_profile_backfill": table_name}, - ) - .on_conflict_do_update( - index_elements=["actor_id", "profile_type", "scope_type", "scope_id"], - set_={ - "status": row["status"], - "skill_tags": row["skill_tags"] or [], - "profile_metadata": {"legacy_profile_backfill": table_name}, - "updated_at": sa.func.now(), - }, - ) - ) - - -def _restore_legacy_profiles(connection, table_name: str, profile_type: str) -> None: - """Restore a legacy profile table from actor profile rows during downgrade.""" - legacy_profiles_table = _legacy_profile_table(table_name) - rows = ( - connection.execute( - sa.text( - """ - select - p.id, - p.actor_id, - i.external_subject, - i.external_issuer, - i.display_name, - i.email, - p.skill_tags, - p.status - from actor_profiles p - join actor_identities i on i.actor_id = p.actor_id - where p.profile_type = :profile_type - and p.scope_type = 'global' - and p.scope_id = 'global' - """ - ), - {"profile_type": profile_type}, - ) - .mappings() - .all() - ) - for row in rows: - connection.execute( - sa.insert(legacy_profiles_table).values( - id=row["id"] or str(uuid4()), - actor_id=row["actor_id"], - external_subject=row["external_subject"], - external_issuer=row["external_issuer"], - display_name=row["display_name"], - email=row["email"], - skill_tags=row["skill_tags"] or [], - status=row["status"], - ) - ) - - -def _legacy_profile_table(table_name: str) -> sa.TableClause: - """Return a typed table clause for restored legacy profile rows.""" - return sa.table( - table_name, - sa.column("id", sa.String), - sa.column("actor_id", sa.String), - sa.column("external_subject", sa.String), - sa.column("external_issuer", sa.String), - sa.column("display_name", sa.String), - sa.column("email", sa.String), - sa.column("skill_tags", sa.JSON), - sa.column("status", sa.String), - ) diff --git a/backend/app/api/deps/auth.py b/backend/app/api/deps/auth.py index a0a06dd..bc53d75 100644 --- a/backend/app/api/deps/auth.py +++ b/backend/app/api/deps/auth.py @@ -6,12 +6,13 @@ from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from app.core.auth import get_auth_verifier from app.db.session import get_db_session from app.interfaces.auth import AuthVerificationError, AuthVerifier -from app.modules.actors.service import ActorService +from app.modules.actors.service import ActorRegistryError, ActorService from app.schemas.auth import ActorContext bearer_scheme = HTTPBearer(auto_error=False) @@ -72,5 +73,15 @@ async def get_registered_actor( The same verified actor context. Route authorization must still use this token-derived context, not persisted profile rows. """ - await ActorService(session).register_actor(actor) + try: + await ActorService(session).register_actor(actor) + except ActorRegistryError as exc: + await session.rollback() + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + except SQLAlchemyError as exc: + await session.rollback() + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Actor registry unavailable", + ) from exc return actor diff --git a/backend/app/api/router.py b/backend/app/api/router.py index a539690..0003af9 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -5,7 +5,6 @@ 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.checkers.router import router as checkers_router from app.modules.projects.router import router as projects_router @@ -15,7 +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") api_router.include_router(checkers_router, prefix="/api/v1") diff --git a/backend/app/api/routes/demo.py b/backend/app/api/routes/demo.py deleted file mode 100644 index 30ed5ef..0000000 --- a/backend/app/api/routes/demo.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Local demo route helpers for exercising local-only flows.""" - -from __future__ import annotations - -from fastapi import APIRouter, HTTPException -from app.core.config import Settings - -DEMO_ENVIRONMENTS = {"local", "dev", "development", "test"} - -router = APIRouter(prefix="/demo", tags=["demo"]) - - -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") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f2957b8..5a8fd45 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -36,7 +36,6 @@ class Settings(BaseSettings): 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 project_agent_openai_agent_sdk_model: str | None = None project_agent_run_timeout_seconds: float = Field(default=1800.0, gt=0.0, le=7200.0) project_agent_max_prompt_bytes: int = Field(default=2_000_000, gt=0, le=10_000_000) @@ -44,6 +43,7 @@ class Settings(BaseSettings): celery_broker_url: str | None = None celery_result_backend_url: str | None = None celery_task_always_eager: bool = False + actor_registry_refresh_interval_seconds: int = Field(default=300, ge=0, le=86_400) model_config = SettingsConfigDict( env_prefix="WORKSTREAM_", diff --git a/backend/app/modules/actors/models.py b/backend/app/modules/actors/models.py index a3b2962..060ad39 100644 --- a/backend/app/modules/actors/models.py +++ b/backend/app/modules/actors/models.py @@ -14,6 +14,8 @@ ACTOR_PROFILE_STATUSES = ("observed", "active", "disabled") GLOBAL_PROFILE_SCOPE_TYPE = "global" GLOBAL_PROFILE_SCOPE_ID = "global" +_PROFILE_TYPE_CHECK_VALUES = ", ".join(f"'{profile_type}'" for profile_type in ACTOR_PROFILE_TYPES) +_PROFILE_STATUS_CHECK_VALUES = ", ".join(f"'{status}'" for status in ACTOR_PROFILE_STATUSES) class ActorIdentity(Base): @@ -57,11 +59,11 @@ class ActorProfile(Base): __tablename__ = "actor_profiles" __table_args__ = ( CheckConstraint( - "profile_type in ('worker', 'reviewer', 'admin', 'project_manager', 'project_owner')", + f"profile_type in ({_PROFILE_TYPE_CHECK_VALUES})", name="ck_actor_profiles_profile_type", ), CheckConstraint( - "status in ('observed', 'active', 'disabled')", + f"status in ({_PROFILE_STATUS_CHECK_VALUES})", name="ck_actor_profiles_status", ), UniqueConstraint( @@ -93,4 +95,3 @@ class ActorProfile(Base): ) identity: Mapped[ActorIdentity] = relationship(back_populates="profiles") - diff --git a/backend/app/modules/actors/repository.py b/backend/app/modules/actors/repository.py index cf0496f..17b6041 100644 --- a/backend/app/modules/actors/repository.py +++ b/backend/app/modules/actors/repository.py @@ -22,16 +22,27 @@ def __init__(self, session: AsyncSession) -> None: """ self._session = session - async def get_identity(self, actor_id: str) -> ActorIdentity | None: + async def get_identity( + self, + actor_id: str, + *, + populate_existing: bool = False, + ) -> ActorIdentity | None: """Load an actor identity by stable Workstream actor id. Args: actor_id: Stable actor id. + populate_existing: Whether to overwrite any existing session state + with the current database row. Returns: Actor identity when present; otherwise ``None``. """ - return await self._session.get(ActorIdentity, actor_id) + return await self._session.get( + ActorIdentity, + actor_id, + populate_existing=populate_existing, + ) async def upsert_identity(self, identity: ActorIdentity) -> ActorIdentity: """Create or refresh an actor identity from trusted token claims. @@ -72,7 +83,7 @@ async def upsert_identity(self, identity: ActorIdentity) -> ActorIdentity: ) ) await self._session.flush() - persisted = await self.get_identity(identity.actor_id) + persisted = await self.get_identity(identity.actor_id, populate_existing=True) if persisted is None: raise RuntimeError("actor identity upsert did not return a persisted row") return persisted @@ -151,4 +162,3 @@ async def insert_profile_if_absent(self, profile: ActorProfile) -> None: ) ) await self._session.flush() - diff --git a/backend/app/modules/actors/service.py b/backend/app/modules/actors/service.py index 748011e..66c0b3c 100644 --- a/backend/app/modules/actors/service.py +++ b/backend/app/modules/actors/service.py @@ -3,11 +3,13 @@ from __future__ import annotations from collections.abc import Iterable +from datetime import UTC, datetime, timedelta from uuid import uuid4 from sqlalchemy import func from sqlalchemy.ext.asyncio import AsyncSession +from app.core.config import get_settings from app.core.permissions import require_any_role from app.modules.actors.models import ( ACTOR_PROFILE_TYPES, @@ -59,7 +61,14 @@ async def register_actor(self, actor: ActorContext) -> ActorIdentity: Returns: Persisted actor identity. """ - identity = await self._repo.upsert_identity(self._identity_from_actor(actor)) + incoming_identity = self._identity_from_actor(actor) + existing_identity = await self._repo.get_identity(actor.actor_id, populate_existing=True) + if await self._can_skip_registry_refresh(actor, incoming_identity, existing_identity): + if existing_identity is None: + raise RuntimeError("actor registry freshness check returned an empty identity") + return existing_identity + + identity = await self._repo.upsert_identity(incoming_identity) for profile_type in self._observed_profile_types(actor.roles): await self.ensure_observed_profile( actor, @@ -137,37 +146,42 @@ async def ensure_observed_profile( return profile if existing.status != "observed": - existing.updated_at = func.now() return existing - next_metadata = profile_metadata or existing.profile_metadata + next_metadata = existing.profile_metadata if profile_metadata is None else profile_metadata + if existing.profile_metadata == next_metadata: + return existing + + previous_metadata = dict(existing.profile_metadata or {}) + existing.profile_metadata = next_metadata existing.updated_at = func.now() - if existing.profile_metadata != next_metadata: - previous_metadata = dict(existing.profile_metadata or {}) - existing.profile_metadata = next_metadata - await self._write_profile_audit( - actor, - existing, - event_type="actor_profile_observation_refreshed", - from_status=existing.status, - to_status=existing.status, - event_payload={ - "previous_profile_metadata": previous_metadata, - "profile_metadata": existing.profile_metadata, - }, - ) + await self._write_profile_audit( + actor, + existing, + event_type="actor_profile_observation_refreshed", + from_status=existing.status, + to_status=existing.status, + event_payload={ + "previous_profile_metadata": previous_metadata, + "profile_metadata": existing.profile_metadata, + }, + ) return existing async def activate_worker_profile( self, actor: ActorContext, payload: ActorProfileActivationRequest, + *, + identity_already_refreshed: bool = False, ) -> ActorProfileResponse: """Activate or refresh the current actor's worker profile. Args: actor: Trusted actor context. payload: Worker-owned profile fields. + identity_already_refreshed: Whether the request dependency already + refreshed the identity row. Returns: Profile response joined with trusted identity metadata. @@ -176,7 +190,10 @@ async def activate_worker_profile( ActorProfileDisabled: If the worker profile has been disabled. """ require_any_role(actor, {"worker"}) - identity = await self._repo.upsert_identity(self._identity_from_actor(actor)) + identity = await self._identity_for_profile_mutation( + actor, + identity_already_refreshed=identity_already_refreshed, + ) profile = await self._repo.get_profile( actor.actor_id, "worker", @@ -260,6 +277,87 @@ async def get_active_profile(self, actor_id: str, profile_type: str) -> ActorPro return None return profile + async def _can_skip_registry_refresh( + self, + actor: ActorContext, + incoming_identity: ActorIdentity, + existing_identity: ActorIdentity | None, + ) -> bool: + """Return whether actor registry persistence can be skipped safely.""" + if existing_identity is None: + return False + interval_seconds = get_settings().actor_registry_refresh_interval_seconds + if interval_seconds <= 0: + return False + if not self._identity_claims_match(existing_identity, incoming_identity): + return False + if not self._identity_seen_recently(existing_identity.last_seen_at, interval_seconds): + return False + required_profiles = [ + { + "profile_type": profile_type, + "scope_type": GLOBAL_PROFILE_SCOPE_TYPE, + "scope_id": GLOBAL_PROFILE_SCOPE_ID, + } + for profile_type in self._observed_profile_types(actor.roles) + ] + required_profiles.extend(self._trusted_relationship_profiles(actor)) + for required_profile in required_profiles: + profile = await self._repo.get_profile( + actor.actor_id, + required_profile["profile_type"], + required_profile["scope_type"], + required_profile["scope_id"], + ) + if profile is None: + return False + return True + + def _identity_claims_match( + self, + existing_identity: ActorIdentity, + incoming_identity: ActorIdentity, + ) -> bool: + """Return whether stored identity fields match the trusted actor claims.""" + return ( + existing_identity.external_subject == incoming_identity.external_subject + and existing_identity.external_issuer == incoming_identity.external_issuer + and existing_identity.display_name == incoming_identity.display_name + and existing_identity.email == incoming_identity.email + and existing_identity.last_seen_roles == incoming_identity.last_seen_roles + and existing_identity.last_claim_snapshot == incoming_identity.last_claim_snapshot + and existing_identity.auth_source == incoming_identity.auth_source + and existing_identity.is_dev_auth == incoming_identity.is_dev_auth + ) + + def _identity_seen_recently( + self, + last_seen_at: datetime | None, + interval_seconds: int, + ) -> bool: + """Return whether an identity row is still within the refresh window.""" + if last_seen_at is None: + return False + comparable_last_seen = last_seen_at + if comparable_last_seen.tzinfo is None: + comparable_last_seen = comparable_last_seen.replace(tzinfo=UTC) + return datetime.now(UTC) - comparable_last_seen <= timedelta(seconds=interval_seconds) + + async def _identity_for_profile_mutation( + self, + actor: ActorContext, + *, + identity_already_refreshed: bool, + ) -> ActorIdentity: + """Return the current identity row for a profile mutation.""" + if not identity_already_refreshed: + return await self._repo.upsert_identity(self._identity_from_actor(actor)) + + identity = await self._repo.get_identity(actor.actor_id, populate_existing=True) + if identity is not None: + return identity + return await self._repo.upsert_identity(self._identity_from_actor(actor)) + def _identity_from_actor(self, actor: ActorContext) -> ActorIdentity: """Build an identity model from trusted actor claims.""" return ActorIdentity( diff --git a/backend/app/modules/tasks/router.py b/backend/app/modules/tasks/router.py index c022d48..f4c89bd 100644 --- a/backend/app/modules/tasks/router.py +++ b/backend/app/modules/tasks/router.py @@ -97,7 +97,11 @@ async def ensure_worker_profile( ) -> ActorProfileResponse: """Create or refresh the current worker profile from trusted Flow identity.""" try: - return await ActorService(session).activate_worker_profile(actor, payload) + return await ActorService(session).activate_worker_profile( + actor, + payload, + identity_already_refreshed=True, + ) except PermissionDenied as exc: raise permission_http_error(exc) from exc except ActorRegistryError as exc: diff --git a/backend/scripts/week1_api_e2e.py b/backend/scripts/api_contract_e2e.py similarity index 97% rename from backend/scripts/week1_api_e2e.py rename to backend/scripts/api_contract_e2e.py index 963d00b..e730bae 100644 --- a/backend/scripts/week1_api_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1,4 +1,4 @@ -"""Run a real HTTP Week 1 API flow against Postgres and local Flow tokens.""" +"""Run a real HTTP backend API contract flow against Postgres and local Flow tokens.""" from __future__ import annotations @@ -237,7 +237,7 @@ def start_api_server(port: int, env: dict[str, str]) -> tuple[subprocess.Popen, Returns: Server process and log path. """ - log_path = project_root() / ".week1_api_e2e_server.log" + log_path = project_root() / ".api_contract_e2e_server.log" log_file = log_path.open("w", encoding="utf-8") process = subprocess.Popen( [ @@ -433,7 +433,7 @@ def assert_local_database_url(database_url: str) -> None: if is_local_async_postgres or override == NONLOCAL_DATABASE_OVERRIDE_VALUE: return raise RuntimeError( - "Refusing to run Week 1 API E2E against a non-local database. " + "Refusing to run API contract E2E against a non-local database. " "Use an async Postgres URL such as postgresql+asyncpg:// on " "localhost/127.0.0.1 with a local test database named " "workstream_test or test_workstream, or set " @@ -520,7 +520,7 @@ async def load_pre_submit_checker_policy(effective_policy: dict) -> dict: def submission_artifact_policy_body() -> dict: - """Build the project submission artifact policy used by the Week 1 drill. + """Build the project submission artifact policy used by the API contract drill. Returns: Machine-readable artifact policy payload. @@ -584,7 +584,7 @@ async def create_policy_bundle_for_guide( "items": [ { "source_kind": "inline_markdown", - "durable_ref": f"inline:/week1/{run_id}/guide", + "durable_ref": f"inline:/api-contract/{run_id}/guide", "ingestion_adapter": "manual_import", "content_hash": sha256_token(f"{run_id}:guide"), "media_type": "text/markdown", @@ -602,7 +602,7 @@ async def create_policy_bundle_for_guide( "source_snapshot_id": snapshot["id"], "status": "passed", "findings": [], - "summary": "Guide is sufficient for the Week 1 real API drill.", + "summary": "Guide is sufficient for the API contract real API drill.", }, 201, ) @@ -624,14 +624,14 @@ async def create_policy_bundle_for_guide( f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/" f"{policy['id']}/approve", manager_token, - {"approval_note": "Approved for Week 1 real API drill."}, + {"approval_note": "Approved for API contract real API drill."}, ) await load_pre_submit_checker_policy(effective_policy) return effective_policy -async def exercise_week1_api(base_url: str, env: dict[str, str]) -> None: - """Run the real Project -> Task -> Submission Week 1 API flow. +async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: + """Run the real Project -> Task -> Submission API contract flow. Args: base_url: Real API server base URL. @@ -730,9 +730,9 @@ async def exercise_week1_api(base_url: str, env: dict[str, str]) -> None: "/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", + "name": f"API Contract Real API {run_id}", + "slug": f"api-contract-real-api-{run_id}", + "description": "Real backend API contract lifecycle QA", }, 201, ) @@ -798,7 +798,7 @@ async def exercise_week1_api(base_url: str, env: dict[str, str]) -> None: manager_token, { "title": "Real API task", - "description": "Exercise the full Week 1 API lifecycle over HTTP.", + "description": "Exercise the full backend API contract lifecycle over HTTP.", "task_type": "evaluation", "difficulty": "medium", "skill_tags": ["stem", "proofs"], @@ -904,7 +904,7 @@ async def exercise_week1_api(base_url: str, env: dict[str, str]) -> None: "hash": f"sha256:evidence-{run_id}", "size_bytes": 256, "metadata": { - "command": "week1_api_e2e", + "command": "api_contract_e2e", "required_evidence_key": "checker_log", }, } @@ -1014,7 +1014,7 @@ async def exercise_week1_api(base_url: str, env: dict[str, str]) -> None: expected_status=403, ) - await assert_week1_database_invariants( + await assert_api_contract_database_invariants( project_id=project["id"], guide_id=guide["id"], task_id=task["id"], @@ -1024,7 +1024,7 @@ async def exercise_week1_api(base_url: str, env: dict[str, str]) -> None: flow_issuer=flow_issuer, ) - print("Week 1 real API e2e passed") + print("API contract real API e2e passed") print(f"project_id={project['id']}") print(f"guide_id={guide['id']}") print(f"task_id={task['id']}") @@ -1033,7 +1033,7 @@ async def exercise_week1_api(base_url: str, env: dict[str, str]) -> None: print(f"submission_locked_at={locked['locked_at']}") -async def assert_week1_database_invariants( +async def assert_api_contract_database_invariants( *, project_id: str, guide_id: str, @@ -1043,7 +1043,7 @@ async def assert_week1_database_invariants( worker_subject: str, flow_issuer: str, ) -> None: - """Verify Week 1 API calls produced the expected durable database state. + """Verify API contract calls produced the expected durable database state. Args: project_id: Created project id. @@ -1288,11 +1288,11 @@ async def assert_week1_database_invariants( "audit issuer drifted", ) - print("PASS Week 1 database invariants") + print("PASS API contract database invariants") async def main(env: dict[str, str]) -> None: - """Start the API server and exercise every Week 1 API. + """Start the API server and exercise the backend API contract. Args: env: Environment variables for the API server. @@ -1304,7 +1304,7 @@ async def main(env: dict[str, str]) -> None: 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) + await exercise_api_contract(base_url, env) finally: process.terminate() try: diff --git a/backend/scripts/week1_dry_run.py b/backend/scripts/week1_dry_run.py deleted file mode 100644 index 17f9b7f..0000000 --- a/backend/scripts/week1_dry_run.py +++ /dev/null @@ -1,322 +0,0 @@ -"""Run the Week 1 backend lifecycle dry run against a configured database.""" - -from __future__ import annotations - -import asyncio -import hashlib -import os -from pathlib import Path -from uuid import uuid4 - -from alembic import command -from alembic.config import Config -from httpx import ASGITransport, AsyncClient - -from app.core.config import get_settings -from app.db import session as db_session -from app.main import create_app - -TOKEN = "week1-dry-run-token" -ISSUER = "flow-dry-run" -STRONG_ATTESTATION = ( - "I attest this is original dry run originality work with no confidential client data, " - "credentials, secrets, tokens, passwords, API keys, private source material, source code, " - "copied platform artifacts, or copied platform content. I confirm credential and secret " - "exclusion and accept human accountability for agent assisted work." -) - - -def set_actor(*, subject: str, roles: str) -> None: - """Set dev-auth actor environment for the next in-process API request. - - Args: - subject: External Flow subject represented by the dev verifier. - roles: Comma-separated Workstream roles for the actor. - """ - os.environ["WORKSTREAM_AUTH_PROVIDER"] = "dev" - os.environ["WORKSTREAM_ENVIRONMENT"] = "local" - os.environ["WORKSTREAM_DEV_AUTH_TOKEN"] = TOKEN - os.environ["WORKSTREAM_DEV_AUTH_SUBJECT"] = subject - os.environ["WORKSTREAM_DEV_AUTH_ISSUER"] = ISSUER - os.environ["WORKSTREAM_DEV_AUTH_EMAIL"] = f"{subject}@flow.local" - os.environ["WORKSTREAM_DEV_AUTH_DISPLAY_NAME"] = subject.replace("-", " ").title() - os.environ["WORKSTREAM_DEV_AUTH_ROLES"] = roles - get_settings.cache_clear() - - -def auth_headers() -> dict[str, str]: - """Build the bearer token header used by the dev verifier. - - Returns: - Authorization header for dry-run requests. - """ - return {"Authorization": f"Bearer {TOKEN}"} - - -def alembic_config() -> Config: - """Create Alembic configuration for the backend project. - - Returns: - Alembic configuration pointing at the local backend migration folder. - """ - project_root = Path(__file__).resolve().parents[1] - config = Config(str(project_root / "alembic.ini")) - config.set_main_option("script_location", str(project_root / "alembic")) - return config - - -async def post_ok(client: AsyncClient, url: str, payload: dict | None = None) -> dict: - """POST JSON and return a successful response body. - - Args: - client: In-process HTTP client. - url: API route to call. - payload: Optional JSON body. - - Returns: - Parsed JSON response. - """ - response = await client.post(url, headers=auth_headers(), json=payload) - if response.status_code >= 400: - raise RuntimeError(f"POST {url} failed: {response.status_code} {response.text}") - return response.json() - - -def sha256_token(value: str) -> str: - """Return a Workstream sha256 token for deterministic dry-run fixtures.""" - return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest() - - -def submission_artifact_policy_body() -> dict: - """Return a minimal project submission artifact policy for the dry run.""" - return { - "required_artifacts": [ - { - "key": "answer", - "path": "answer.md", - "hash_required": True, - "required": True, - "description": "Main answer artifact.", - } - ], - "required_evidence": [ - { - "key": "checker_log", - "label": "checker log", - "hash_required": True, - "required": True, - "description": "Evidence item used by the reviewer.", - } - ], - "forbidden_artifacts": [], - "attestation_terms": ["dry_run_originality"], - "manifest_required": True, - "artifact_hash_required": True, - "artifact_hash_algorithm": "sha256", - "allowed_storage_schemes": ["local", "s3", "r2"], - "maximum_file_size_bytes": 1_000_000, - "maximum_package_size_bytes": 5_000_000, - "packaging": {"package_required": False}, - } - - -async def create_policy_bundle_for_guide( - client: AsyncClient, - project_id: str, - guide_id: str, - run_id: str, -) -> None: - """Create the guide-source, sufficiency, and approved policy bundle.""" - snapshot = await post_ok( - client, - f"/api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots", - { - "items": [ - { - "source_kind": "inline_markdown", - "durable_ref": f"inline:/guides/{guide_id}/{run_id}", - "ingestion_adapter": "manual_import", - "content_hash": sha256_token(f"{run_id}:guide"), - "media_type": "text/markdown", - } - ] - }, - ) - await post_ok( - client, - f"/api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports", - { - "source_snapshot_id": snapshot["id"], - "status": "passed", - "findings": [], - "summary": "Guide is sufficient for the Week 1 dry run.", - }, - ) - policy = await post_ok( - client, - f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies", - { - "source_snapshot_id": snapshot["id"], - "policy_version": "v1", - "policy_body": submission_artifact_policy_body(), - }, - ) - await post_ok( - client, - f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/" - f"{policy['id']}/approve", - {"approval_note": "Approved for Week 1 dry run."}, - ) - - -async def main() -> None: - """Execute Project -> Guide -> Task -> Submit -> Lock dry run.""" - await db_session.dispose_engine() - - run_id = uuid4().hex[:8] - worker_subject = f"dry-run-worker-{run_id}" - app = create_app() - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://week1-dry-run", - ) as client: - project = await post_ok( - client, - "/api/v1/projects", - { - "name": f"Week 1 Dry Run {run_id}", - "slug": f"week1-dry-run-{run_id}", - "description": "Week 1 backend closure dry run", - }, - ) - guide = await post_ok( - client, - f"/api/v1/projects/{project['id']}/guides", - { - "version": "v1", - "content_markdown": "# Week 1 Dry Run Guide", - "change_summary": "Initial dry-run guide", - "post_submit_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", - }, - }, - ) - await create_policy_bundle_for_guide(client, project["id"], guide["id"], run_id) - await post_ok(client, f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate") - task = await post_ok( - client, - f"/api/v1/projects/{project['id']}/tasks", - { - "title": "Week 1 dry-run task", - "description": "Prove the backend lifecycle through submission.", - "task_type": "evaluation", - "difficulty": "medium", - "skill_tags": ["stem", "proofs"], - "estimated_time_minutes": 45, - "source_type": "manual", - "source_ref": f"dry-run-{run_id}", - "source_payload_hash": f"sha256:source-{run_id}", - "acceptance_criteria": "The submission packet is complete.", - "rejection_criteria": "Evidence is missing.", - }, - ) - await post_ok( - client, - f"/api/v1/tasks/{task['id']}/screen", - {"reason": "dry-run screening passed"}, - ) - await post_ok( - client, - f"/api/v1/tasks/{task['id']}/release", - {"reason": "dry-run release decision"}, - ) - - set_actor(subject=worker_subject, roles="worker") - await post_ok( - client, - "/api/v1/workers/me/profile", - {"skill_tags": ["stem", "proofs"]}, - ) - claim = await post_ok( - client, - f"/api/v1/tasks/{task['id']}/claim", - {"reason": "dry-run claim"}, - ) - await post_ok( - client, - f"/api/v1/tasks/{task['id']}/start", - {"reason": "dry-run start"}, - ) - submission = await post_ok( - client, - f"/api/v1/tasks/{task['id']}/submissions", - { - "summary": "Dry-run packet completed.", - "package_uri": f"local://dry-run/{run_id}/package.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": "dry-run artifact", - } - ], - "worker_attestation": STRONG_ATTESTATION, - "evidence_items": [ - { - "type": "log", - "label": "dry-run evidence", - "uri": f"local://dry-run/{run_id}/evidence.log", - "hash": f"sha256:evidence-{run_id}", - "size_bytes": 256, - "metadata": {"command": "week1_dry_run", "policy_key": "checker_log"}, - } - ], - }, - ) - - set_actor(subject="dry-run-project-manager", roles="project_manager") - locked = await post_ok(client, f"/api/v1/submissions/{submission['id']}/lock") - - print("Week 1 dry run 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']}") - - -if __name__ == "__main__": - if not os.environ.get("WORKSTREAM_DATABASE_URL"): - os.environ["WORKSTREAM_DATABASE_URL"] = ( - "postgresql+asyncpg://workstream:workstream@localhost:5433/workstream" - ) - set_actor(subject="dry-run-project-manager", roles="project_manager") - command.upgrade(alembic_config(), "head") - asyncio.run(main()) diff --git a/backend/scripts/week2_api_e2e.py b/backend/scripts/week2_api_e2e.py index 51b705c..77f7c04 100644 --- a/backend/scripts/week2_api_e2e.py +++ b/backend/scripts/week2_api_e2e.py @@ -17,9 +17,9 @@ from app.db import session as db_session from app.modules.checkers.models import CheckerResult, CheckerRun from app.modules.tasks.models import AuditEvent, EvidenceItem, Submission, WorkstreamTask -from week1_api_e2e import ( +from api_contract_e2e import ( alembic_config, - api_environment as week1_api_environment, + api_environment as contract_api_environment, create_policy_bundle_for_guide, find_free_port, flow_settings, @@ -78,7 +78,7 @@ def ensure(condition: bool, message: str) -> None: def api_environment() -> dict[str, str]: """Build the API environment and honor the contract's test DB variable.""" - env = week1_api_environment() + env = contract_api_environment() test_database_url = os.environ.get(TEST_DATABASE_URL_ENV) if test_database_url: env[DATABASE_URL_ENV] = test_database_url diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index 988a372..027da75 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -151,7 +151,7 @@ async def test_auth_me_registers_identity_and_observed_profiles_without_duplicat ] -async def test_repeated_auth_me_refreshes_profile_freshness_without_status_drift( +async def test_repeated_auth_me_does_not_rewrite_unchanged_observed_profile( actor_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -184,7 +184,7 @@ async def test_repeated_auth_me_refreshes_profile_freshness_without_status_drift assert len(profile_rows) == 1 assert profile_rows[0].status == "observed" - assert profile_rows[0].updated_at > stale_time + assert profile_rows[0].updated_at == stale_time @pytest.mark.parametrize("role", ["worker", "reviewer", "admin", "project_manager"]) @@ -293,6 +293,38 @@ async def test_observation_preserves_active_and_disabled_statuses( assert disabled_profile.profile_metadata == {"source": "worker_profile_api"} +async def test_observation_can_explicitly_clear_metadata(actor_database_env: str) -> None: + actor = actor_context(subject="metadata-clear-worker", roles=("worker",)) + async with db_session.get_session_factory()() as session: + service = ActorService(session) + await service.ensure_observed_profile( + actor, + profile_type="worker", + scope_type="global", + scope_id="global", + profile_metadata={"source": "initial"}, + ) + await service.ensure_observed_profile( + actor, + profile_type="worker", + scope_type="global", + scope_id="global", + profile_metadata={}, + ) + await session.commit() + + async with db_session.get_session_factory()() as session: + profile = await session.scalar( + select(ActorProfile).where( + ActorProfile.actor_id == actor.actor_id, + ActorProfile.profile_type == "worker", + ) + ) + + assert profile is not None + assert profile.profile_metadata == {} + + async def test_scoped_project_owner_profile_comes_from_trusted_relationship_claim( actor_database_env: str, ) -> None: diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index a25d013..a203006 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -147,70 +147,27 @@ def test_post_submit_policy_upgrade_blocks_pre_provenance_runtime_rows( assert columns_exist is False -def test_actor_profile_registry_backfills_and_removes_legacy_profile_tables( +def test_actor_profile_registry_removes_obsolete_profile_tables( isolated_database_env: str, migration_lock, ) -> None: - """Prove legacy profile rows move into the shared actor registry.""" + """Prove obsolete profile tables are removed from the current schema.""" project_root = Path(__file__).resolve().parents[1] config = Config(str(project_root / "alembic.ini")) config.set_main_option("script_location", str(project_root / "alembic")) - worker_actor_id = actor_id_from_external_identity("flow-test", "legacy-worker") - reviewer_actor_id = actor_id_from_external_identity("flow-test", "legacy-reviewer") - ids = { - "worker": str(uuid4()), - "reviewer": str(uuid4()), - "worker_actor": worker_actor_id, - "reviewer_actor": reviewer_actor_id, - } with migration_lock(): try: command.downgrade(config, "base") - command.upgrade(config, "0011_task_artifact_cleanup") - asyncio.run(_seed_legacy_profile_rows(isolated_database_env, ids)) command.upgrade(config, "head") - head_state = asyncio.run(_fetch_actor_registry_state(isolated_database_env)) - - command.downgrade(config, "0011_task_artifact_cleanup") - downgraded_tables = asyncio.run(_fetch_table_names(isolated_database_env)) - downgraded_state = asyncio.run(_fetch_legacy_profile_state(isolated_database_env)) + table_names = asyncio.run(_fetch_table_names(isolated_database_env)) finally: command.downgrade(config, "base") - assert "worker_profiles" not in head_state["tables"] - assert "reviewer_profiles" not in head_state["tables"] - assert head_state["identities"] == { - ("legacy-worker", "flow-test"), - ("legacy-reviewer", "flow-test"), - } - assert head_state["profiles"] == { - ("legacy-worker", "worker", "active", ("stem", "python"), "global", "global"), - ("legacy-reviewer", "reviewer", "active", ("review",), "global", "global"), - } - assert {"worker_profiles", "reviewer_profiles"}.issubset(downgraded_tables) - assert downgraded_state["worker_profiles"] == { - ( - worker_actor_id, - "legacy-worker", - "flow-test", - "Legacy Worker", - "legacy-worker@example.test", - ("stem", "python"), - "active", - ) - } - assert downgraded_state["reviewer_profiles"] == { - ( - reviewer_actor_id, - "legacy-reviewer", - "flow-test", - "Legacy Reviewer", - "legacy-reviewer@example.test", - ("review",), - "active", - ) - } + assert "actor_identities" in table_names + assert "actor_profiles" in table_names + assert "worker_profiles" not in table_names + assert "reviewer_profiles" not in table_names def test_actor_profile_registry_unique_constraints_are_enforced( @@ -273,77 +230,6 @@ async def _fetch_table_names(database_url: str) -> set[str]: await engine.dispose() -async def _seed_legacy_profile_rows(database_url: str, ids: dict[str, str]) -> None: - """Seed old profile tables before the actor registry migration.""" - engine = create_async_engine(database_url) - try: - async with engine.begin() as connection: - await connection.execute( - text( - """ - insert into worker_profiles ( - id, - actor_id, - external_subject, - external_issuer, - display_name, - email, - skill_tags, - status - ) - values ( - :id, - :actor_id, - 'legacy-worker', - 'flow-test', - 'Legacy Worker', - 'legacy-worker@example.test', - cast(:skill_tags as json), - 'active' - ) - """ - ), - { - "id": ids["worker"], - "actor_id": ids["worker_actor"], - "skill_tags": json.dumps(["stem", "python"]), - }, - ) - await connection.execute( - text( - """ - insert into reviewer_profiles ( - id, - actor_id, - external_subject, - external_issuer, - display_name, - email, - skill_tags, - status - ) - values ( - :id, - :actor_id, - 'legacy-reviewer', - 'flow-test', - 'Legacy Reviewer', - 'legacy-reviewer@example.test', - cast(:skill_tags as json), - 'active' - ) - """ - ), - { - "id": ids["reviewer"], - "actor_id": ids["reviewer_actor"], - "skill_tags": json.dumps(["review"]), - }, - ) - finally: - await engine.dispose() - - async def _assert_actor_registry_unique_constraints(database_url: str) -> None: """Insert duplicates and prove actor registry unique constraints reject them.""" engine = create_async_engine(database_url) @@ -523,112 +409,6 @@ async def _expect_integrity_error(engine, statement, params: dict) -> None: await connection.execute(statement, params) -async def _fetch_actor_registry_state(database_url: str) -> dict[str, set]: - """Return actor registry rows and table names for migration assertions.""" - engine = create_async_engine(database_url) - try: - async with engine.begin() as connection: - tables = await _fetch_table_names(database_url) - identities = ( - await connection.execute( - text( - """ - select external_subject, external_issuer - from actor_identities - order by external_subject - """ - ) - ) - ).all() - profiles = ( - await connection.execute( - text( - """ - select - i.external_subject, - p.profile_type, - p.status, - p.skill_tags, - p.scope_type, - p.scope_id - from actor_profiles p - join actor_identities i on i.actor_id = p.actor_id - order by i.external_subject, p.profile_type - """ - ) - ) - ).all() - return { - "tables": tables, - "identities": { - (row.external_subject, row.external_issuer) - for row in identities - }, - "profiles": { - ( - row.external_subject, - row.profile_type, - row.status, - tuple(row.skill_tags), - row.scope_type, - row.scope_id, - ) - for row in profiles - }, - } - finally: - await engine.dispose() - - -async def _fetch_legacy_profile_state(database_url: str) -> dict[str, set]: - """Return restored legacy profile rows after actor registry downgrade.""" - engine = create_async_engine(database_url) - try: - async with engine.begin() as connection: - worker_rows = await _fetch_legacy_profile_rows(connection, "worker_profiles") - reviewer_rows = await _fetch_legacy_profile_rows(connection, "reviewer_profiles") - return { - "worker_profiles": worker_rows, - "reviewer_profiles": reviewer_rows, - } - finally: - await engine.dispose() - - -async def _fetch_legacy_profile_rows(connection, table_name: str) -> set[tuple]: - """Return comparable legacy profile rows from one restored table.""" - rows = ( - await connection.execute( - text( - f""" - select - actor_id, - external_subject, - external_issuer, - display_name, - email, - skill_tags, - status - from {table_name} - order by external_subject - """ - ) - ) - ).all() - return { - ( - row.actor_id, - row.external_subject, - row.external_issuer, - row.display_name, - row.email, - tuple(row.skill_tags), - row.status, - ) - for row in rows - } - - async def _seed_pre_provenance_post_submit_policy( database_url: str, project_id: str, diff --git a/demos/week1_api_demo_ui/index.html b/demos/week1_api_demo_ui/index.html deleted file mode 100644 index 196a497..0000000 --- a/demos/week1_api_demo_ui/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Workstream Week 1 API Demo - - -
- - - diff --git a/demos/week1_api_demo_ui/package-lock.json b/demos/week1_api_demo_ui/package-lock.json deleted file mode 100644 index 7a2a0a5..0000000 --- a/demos/week1_api_demo_ui/package-lock.json +++ /dev/null @@ -1,1721 +0,0 @@ -{ - "name": "workstream-week1-api-demo-ui", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "workstream-week1-api-demo-ui", - "version": "0.1.0", - "dependencies": { - "@vitejs/plugin-react": "^5.0.0", - "lucide-react": "^0.468.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "typescript": "^5.7.0", - "vite": "^6.0.0" - }, - "devDependencies": { - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", - "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", - "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", - "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", - "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", - "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", - "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", - "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", - "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", - "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", - "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", - "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", - "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", - "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", - "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", - "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", - "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", - "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", - "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", - "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", - "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", - "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", - "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", - "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", - "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", - "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", - "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.34", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", - "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001797", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", - "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.368", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", - "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", - "license": "ISC" - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.468.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", - "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.7" - } - }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", - "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.61.1", - "@rollup/rollup-android-arm64": "4.61.1", - "@rollup/rollup-darwin-arm64": "4.61.1", - "@rollup/rollup-darwin-x64": "4.61.1", - "@rollup/rollup-freebsd-arm64": "4.61.1", - "@rollup/rollup-freebsd-x64": "4.61.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", - "@rollup/rollup-linux-arm-musleabihf": "4.61.1", - "@rollup/rollup-linux-arm64-gnu": "4.61.1", - "@rollup/rollup-linux-arm64-musl": "4.61.1", - "@rollup/rollup-linux-loong64-gnu": "4.61.1", - "@rollup/rollup-linux-loong64-musl": "4.61.1", - "@rollup/rollup-linux-ppc64-gnu": "4.61.1", - "@rollup/rollup-linux-ppc64-musl": "4.61.1", - "@rollup/rollup-linux-riscv64-gnu": "4.61.1", - "@rollup/rollup-linux-riscv64-musl": "4.61.1", - "@rollup/rollup-linux-s390x-gnu": "4.61.1", - "@rollup/rollup-linux-x64-gnu": "4.61.1", - "@rollup/rollup-linux-x64-musl": "4.61.1", - "@rollup/rollup-openbsd-x64": "4.61.1", - "@rollup/rollup-openharmony-arm64": "4.61.1", - "@rollup/rollup-win32-arm64-msvc": "4.61.1", - "@rollup/rollup-win32-ia32-msvc": "4.61.1", - "@rollup/rollup-win32-x64-gnu": "4.61.1", - "@rollup/rollup-win32-x64-msvc": "4.61.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "license": "ISC" - } - } -} diff --git a/demos/week1_api_demo_ui/package.json b/demos/week1_api_demo_ui/package.json deleted file mode 100644 index c0c2b52..0000000 --- a/demos/week1_api_demo_ui/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "workstream-week1-api-demo-ui", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite --host 127.0.0.1", - "build": "tsc -b && vite build", - "preview": "vite preview --host 127.0.0.1" - }, - "dependencies": { - "@vitejs/plugin-react": "^5.0.0", - "lucide-react": "^0.468.0", - "typescript": "^5.7.0", - "vite": "^6.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "devDependencies": { - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0" - } -} diff --git a/demos/week1_api_demo_ui/src/App.tsx b/demos/week1_api_demo_ui/src/App.tsx deleted file mode 100644 index 750acbf..0000000 --- a/demos/week1_api_demo_ui/src/App.tsx +++ /dev/null @@ -1,698 +0,0 @@ -import { - Activity, - BadgeCheck, - Check, - ChevronRight, - ClipboardList, - FileCheck2, - KeyRound, - Lock, - Play, - RefreshCcw, - ShieldCheck, - UserCog, - Users -} from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; - -import { - Actor, - ApiEvent, - DemoConfig, - DemoRecords, - guidePayload, - issueToken, - projectPayload, - submissionPayload, - taskPayload -} from "./api"; - -const defaultConfig: DemoConfig = { - issuer: "https://auth.flow.local/demo", - audience: "workstream-demo", - secret: "workstream-demo-local-secret" -}; - -type DemoStep = { - key: string; - label: string; - detail: string; - icon: typeof ClipboardList; - run: () => Promise; -}; - -function eventId() { - return crypto.randomUUID(); -} - -function shortId(value?: string) { - return value ? value.slice(0, 8) : "pending"; -} - -function statusTone(status?: string) { - if (!status) return "muted"; - if (["active", "ready", "submitted"].includes(status)) return "good"; - if (["screening", "claimed", "in_progress"].includes(status)) return "info"; - return "muted"; -} - -export function App() { - const [config, setConfig] = useState(defaultConfig); - const [runId, setRunId] = useState(() => crypto.randomUUID().slice(0, 8)); - const [actors, setActors] = useState>({}); - const [records, setRecords] = useState({}); - const [events, setEvents] = useState([]); - const [activeStep, setActiveStep] = useState(null); - const [selectedRecord, setSelectedRecord] = useState("project"); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - async function createActors() { - const managerSubject = `demo-project-manager-${runId}`; - const workerSubject = `demo-worker-${runId}`; - const reviewerSubject = `demo-reviewer-${runId}`; - const [managerToken, workerToken, reviewerToken] = await Promise.all([ - issueToken(config, managerSubject, ["project_manager"]), - issueToken(config, workerSubject, ["worker"]), - issueToken(config, reviewerSubject, ["reviewer"]) - ]); - if (cancelled) return; - setActors({ - manager: { - label: "Project Manager", - subject: managerSubject, - roles: ["project_manager"], - token: managerToken - }, - worker: { - label: "Worker", - subject: workerSubject, - roles: ["worker"], - token: workerToken - }, - reviewer: { - label: "Reviewer", - subject: reviewerSubject, - roles: ["reviewer"], - token: reviewerToken - } - }); - } - createActors().catch((caught: unknown) => setError(String(caught))); - return () => { - cancelled = true; - }; - }, [config, runId]); - - async function call( - label: string, - method: string, - path: string, - actor?: Actor, - body?: unknown, - expectedStatus?: number - ): Promise { - const response = await fetch(path, { - method, - headers: { - ...(actor ? { Authorization: `Bearer ${actor.token}` } : {}), - ...(body ? { "Content-Type": "application/json" } : {}) - }, - body: body ? JSON.stringify(body) : undefined - }); - const contentType = response.headers.get("content-type") ?? ""; - const data = contentType.includes("application/json") - ? await response.json() - : await response.text(); - const ok = expectedStatus ? response.status === expectedStatus : response.ok; - setEvents((current) => [ - { - id: eventId(), - at: new Date().toLocaleTimeString(), - label, - method, - path, - status: response.status, - actor: actor?.label, - ok - }, - ...current - ]); - if (!ok) { - throw new Error(`${label} returned ${response.status}: ${JSON.stringify(data)}`); - } - return data as T; - } - - async function runStep(key: string, action: () => Promise) { - setError(null); - setActiveStep(key); - try { - await action(); - } catch (caught) { - setError(caught instanceof Error ? caught.message : String(caught)); - } finally { - setActiveStep(null); - } - } - - const steps: DemoStep[] = useMemo( - () => [ - { - key: "health", - label: "Verify API And Flow Tokens", - detail: "Checks health, invalid token rejection, and manager actor context.", - icon: ShieldCheck, - run: async () => { - await call("Health", "GET", "/api/v1/health"); - await call("Invalid token rejected", "GET", "/api/v1/auth/me", { - label: "Invalid Token", - subject: "bad-token", - roles: ["worker"], - token: "bad.token.value" - }, undefined, 401); - await call("Manager token accepted", "GET", "/api/v1/auth/me", actors.manager); - } - }, - { - key: "project", - label: "Create Project", - detail: "Creates the project shell with base payment context.", - icon: ClipboardList, - run: async () => { - const project = await call( - "Create project", - "POST", - "/api/v1/projects", - actors.manager, - projectPayload(runId), - 201 - ); - setRecords((current) => ({ ...current, project })); - setSelectedRecord("project"); - } - }, - { - key: "guide", - label: "Create And Activate Guide", - detail: "Adds guide, checker, review, revision, and payment policy.", - icon: FileCheck2, - run: async () => { - if (!records.project) throw new Error("Create a project first."); - const guide = await call( - "Create guide", - "POST", - `/api/v1/projects/${records.project.id}/guides`, - actors.manager, - guidePayload(runId), - 201 - ); - const patched = await call( - "Patch guide", - "PATCH", - `/api/v1/projects/${records.project.id}/guides/${guide.id}`, - actors.manager, - { change_summary: "Patched from the Week 1 API demo before activation." } - ); - const activeGuide = await call( - "Activate guide", - "POST", - `/api/v1/projects/${records.project.id}/guides/${guide.id}/activate`, - actors.manager - ); - setRecords((current) => ({ ...current, guide: patched, activeGuide })); - setSelectedRecord("activeGuide"); - } - }, - { - key: "task", - label: "Create And Release Task", - detail: "Creates, screens, and releases a task into the ready queue.", - icon: Activity, - run: async () => { - if (!records.project) throw new Error("Create a project first."); - const task = await call( - "Create task", - "POST", - `/api/v1/projects/${records.project.id}/tasks`, - actors.manager, - taskPayload(runId), - 201 - ); - await call("Screen task", "POST", `/api/v1/tasks/${task.id}/screen`, actors.manager, { - reason: "Week 1 API demo screening passed" - }); - const released = await call( - "Release task", - "POST", - `/api/v1/tasks/${task.id}/release`, - actors.manager, - { reason: "Week 1 API demo release" } - ); - setRecords((current) => ({ ...current, task: released })); - setSelectedRecord("task"); - } - }, - { - key: "claim", - label: "Worker Claims And Starts", - detail: "Activates worker profile, claims the task, then starts work.", - icon: Users, - run: async () => { - if (!records.task) throw new Error("Create and release a task first."); - const workerProfile = await call( - "Activate worker profile", - "POST", - "/api/v1/workers/me/profile", - actors.worker, - { skill_tags: ["stem", "proofs"] }, - 200 - ); - const claimed = await call( - "Claim task", - "POST", - `/api/v1/tasks/${records.task.id}/claim`, - actors.worker, - { reason: "Week 1 API demo worker claim" } - ); - const started = await call( - "Start task", - "POST", - `/api/v1/tasks/${records.task.id}/start`, - actors.worker, - { reason: "Week 1 API demo worker start" } - ); - setRecords((current) => ({ - ...current, - workerProfile, - assignment: claimed.assignment, - task: started - })); - setSelectedRecord("assignment"); - } - }, - { - key: "submit", - label: "Submit Packet", - detail: "Worker submits artifact hash manifest, evidence, and attestation.", - icon: BadgeCheck, - run: async () => { - if (!records.task) throw new Error("Start a task first."); - const submission = await call( - "Submit packet", - "POST", - `/api/v1/tasks/${records.task.id}/submissions`, - actors.worker, - submissionPayload(runId), - 201 - ); - setRecords((current) => ({ ...current, submission, task: { ...current.task, status: "submitted" } })); - setSelectedRecord("submission"); - } - }, - { - key: "lock", - label: "Lock Submission And Audit", - detail: "Worker lock is denied; manager lock succeeds; audit events load.", - icon: Lock, - run: async () => { - if (!records.task || !records.submission) throw new Error("Submit a packet first."); - await call( - "Worker lock denied", - "POST", - `/api/v1/submissions/${records.submission.id}/lock`, - actors.worker, - undefined, - 403 - ); - const lockedSubmission = await call( - "Manager lock accepted", - "POST", - `/api/v1/submissions/${records.submission.id}/lock`, - actors.manager - ); - const auditEvents = await call( - "Load audit events", - "GET", - `/api/v1/tasks/${records.task.id}/audit-events`, - actors.manager - ); - await call( - "Reviewer blocked in Week 1", - "GET", - `/api/v1/tasks/${records.task.id}`, - actors.reviewer, - undefined, - 403 - ); - setRecords((current) => ({ ...current, lockedSubmission, auditEvents })); - setSelectedRecord("auditEvents"); - } - } - ], - [actors.manager, actors.reviewer, actors.worker, records, runId] - ); - - async function runAll() { - await runStep("run-all", async () => { - await call("Health", "GET", "/api/v1/health"); - await call("Invalid token rejected", "GET", "/api/v1/auth/me", { - label: "Invalid Token", - subject: "bad-token", - roles: ["worker"], - token: "bad.token.value" - }, undefined, 401); - await call("Manager token accepted", "GET", "/api/v1/auth/me", actors.manager); - - const project = await call( - "Create project", - "POST", - "/api/v1/projects", - actors.manager, - projectPayload(runId), - 201 - ); - setRecords((current) => ({ ...current, project })); - - const guide = await call( - "Create guide", - "POST", - `/api/v1/projects/${project.id}/guides`, - actors.manager, - guidePayload(runId), - 201 - ); - const patchedGuide = await call( - "Patch guide", - "PATCH", - `/api/v1/projects/${project.id}/guides/${guide.id}`, - actors.manager, - { change_summary: "Patched from the Week 1 API demo before activation." } - ); - const activeGuide = await call( - "Activate guide", - "POST", - `/api/v1/projects/${project.id}/guides/${guide.id}/activate`, - actors.manager - ); - setRecords((current) => ({ ...current, guide: patchedGuide, activeGuide })); - - const task = await call( - "Create task", - "POST", - `/api/v1/projects/${project.id}/tasks`, - actors.manager, - taskPayload(runId), - 201 - ); - await call("Screen task", "POST", `/api/v1/tasks/${task.id}/screen`, actors.manager, { - reason: "Week 1 API demo screening passed" - }); - const releasedTask = await call( - "Release task", - "POST", - `/api/v1/tasks/${task.id}/release`, - actors.manager, - { reason: "Week 1 API demo release" } - ); - setRecords((current) => ({ ...current, task: releasedTask })); - - const workerProfile = await call( - "Activate worker profile", - "POST", - "/api/v1/workers/me/profile", - actors.worker, - { skill_tags: ["stem", "proofs"] }, - 200 - ); - const claimed = await call( - "Claim task", - "POST", - `/api/v1/tasks/${task.id}/claim`, - actors.worker, - { reason: "Week 1 API demo worker claim" } - ); - const startedTask = await call( - "Start task", - "POST", - `/api/v1/tasks/${task.id}/start`, - actors.worker, - { reason: "Week 1 API demo worker start" } - ); - setRecords((current) => ({ - ...current, - workerProfile, - assignment: claimed.assignment, - task: startedTask - })); - - const submission = await call( - "Submit packet", - "POST", - `/api/v1/tasks/${task.id}/submissions`, - actors.worker, - submissionPayload(runId), - 201 - ); - setRecords((current) => ({ - ...current, - submission, - task: { ...startedTask, status: "submitted" } - })); - - await call( - "Worker lock denied", - "POST", - `/api/v1/submissions/${submission.id}/lock`, - actors.worker, - undefined, - 403 - ); - const lockedSubmission = await call( - "Manager lock accepted", - "POST", - `/api/v1/submissions/${submission.id}/lock`, - actors.manager - ); - const auditEvents = await call( - "Load audit events", - "GET", - `/api/v1/tasks/${task.id}/audit-events`, - actors.manager - ); - await call( - "Reviewer blocked in Week 1", - "GET", - `/api/v1/tasks/${task.id}`, - actors.reviewer, - undefined, - 403 - ); - setRecords((current) => ({ - ...current, - lockedSubmission, - auditEvents - })); - setSelectedRecord("auditEvents"); - }); - } - - function resetDemo() { - setRunId(crypto.randomUUID().slice(0, 8)); - setRecords({}); - setEvents([]); - setError(null); - setSelectedRecord("project"); - } - - const recordTabs: Array<[keyof DemoRecords, string]> = [ - ["project", "Project"], - ["activeGuide", "Guide"], - ["task", "Task"], - ["assignment", "Assignment"], - ["submission", "Submission"], - ["lockedSubmission", "Locked"], - ["auditEvents", "Audit"] - ]; - - return ( -
- - -
-
-
-

Project Guide To Locked Submission

-

Run the Week 1 lifecycle through the backend APIs with real bearer tokens.

-
-
- - -
-
- - {error &&
{error}
} - -
- - - - -
- -
-
-
-

Lifecycle

- Run ID {runId} -
-
- {steps.map((step) => { - const Icon = step.icon; - const isRunning = activeStep === step.key; - return ( -
-
- {isRunning ? : } -
-
- {step.label} - {step.detail} -
- -
- ); - })} -
-
- -
-
-

Current Records

- {selectedRecord} -
-
- {recordTabs.map(([key, label]) => ( - - ))} -
-
{JSON.stringify(records[selectedRecord] ?? {}, null, 2)}
-
- -
-
-

API Events

- {events.length} calls -
-
- {events.length === 0 &&
No API calls yet.
} - {events.map((event) => ( -
-
- {event.ok ? : event.status} -
-
- {event.label} - - {event.method} - {event.path} - -
- {event.actor ?? "public"} -
- ))} -
-
-
-
-
- ); -} - -function Metric({ - label, - value, - status -}: { - label: string; - value: string; - status?: string; -}) { - return ( -
- {label} - {value} - {status ?? "not created"} -
- ); -} diff --git a/demos/week1_api_demo_ui/src/api.ts b/demos/week1_api_demo_ui/src/api.ts deleted file mode 100644 index 28d246d..0000000 --- a/demos/week1_api_demo_ui/src/api.ts +++ /dev/null @@ -1,205 +0,0 @@ -export type Role = "project_manager" | "worker" | "reviewer"; - -export type DemoConfig = { - issuer: string; - audience: string; - secret: string; -}; - -export type Actor = { - label: string; - subject: string; - roles: Role[]; - token: string; -}; - -export type ApiEvent = { - id: string; - at: string; - label: string; - method: string; - path: string; - status: number; - actor?: string; - ok: boolean; -}; - -export type DemoRecords = { - project?: any; - guide?: any; - activeGuide?: any; - task?: any; - assignment?: any; - workerProfile?: any; - submission?: any; - lockedSubmission?: any; - auditEvents?: any[]; -}; - -const encoder = new TextEncoder(); - -function base64Url(input: string | ArrayBuffer): string { - const bytes = typeof input === "string" ? encoder.encode(input) : new Uint8Array(input); - let binary = ""; - bytes.forEach((byte) => { - binary += String.fromCharCode(byte); - }); - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); -} - -export async function issueToken( - config: DemoConfig, - subject: string, - roles: Role[] -): Promise { - const now = Math.floor(Date.now() / 1000); - const header = base64Url(JSON.stringify({ alg: "HS256", typ: "JWT" })); - const payload = base64Url( - JSON.stringify({ - iss: config.issuer, - aud: config.audience, - sub: subject, - email: `${subject}@flow.local`, - name: subject - .split("-") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "), - roles, - iat: now, - nbf: now - 5, - exp: now + 60 * 60 - }) - ); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(config.secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ); - const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(`${header}.${payload}`)); - return `${header}.${payload}.${base64Url(signature)}`; -} - -export function authHeaders(token?: string): HeadersInit { - return token ? { Authorization: `Bearer ${token}` } : {}; -} - -export async function apiRequest( - method: string, - path: string, - token?: string, - body?: unknown -): Promise<{ data: T; status: number }> { - const response = await fetch(path, { - method, - headers: { - ...authHeaders(token), - ...(body ? { "Content-Type": "application/json" } : {}) - }, - body: body ? JSON.stringify(body) : undefined - }); - const contentType = response.headers.get("content-type") ?? ""; - const data = contentType.includes("application/json") - ? await response.json() - : await response.text(); - if (!response.ok) { - throw Object.assign(new Error(`${method} ${path} failed with ${response.status}`), { - status: response.status, - data - }); - } - return { data: data as T, status: response.status }; -} - -export function projectPayload(runId: string) { - return { - name: `Week 1 Demo ${runId}`, - slug: `week1-demo-${runId}`, - description: "Team demo project running the Week 1 Workstream lifecycle." - }; -} - -export function guidePayload(runId: string) { - return { - version: "v1", - content_markdown: `# Demo Project Guide ${runId} - -Complete the task and submit verifiable evidence. A valid submission includes a -summary, an artifact manifest, an artifact package reference, and evidence that -can be checked against the locked project policy. - -Reviewers check guide compliance, artifact hashes, evidence quality, and whether -the output satisfies the task acceptance criteria.`, - change_summary: "Initial demo guide", - post_submit_checker_policy: { - required_checkers: ["check_policy_context_present", "check_required_files"], - 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" - } - }; -} - -export function taskPayload(runId: string) { - return { - title: `Demo task ${runId}`, - description: "Evaluate the supplied proof and return evidence-backed output.", - task_type: "evaluation", - difficulty: "medium", - skill_tags: ["stem", "proofs"], - estimated_time_minutes: 45, - source_type: "manual", - source_ref: `demo-${runId}`, - source_payload_hash: `sha256:source-${runId}`, - acceptance_criteria: "The submission packet is complete and evidence-backed.", - rejection_criteria: "Evidence is missing or unverifiable." - }; -} - -export function submissionPayload(runId: string) { - return { - summary: "Completed the demo task with artifact hash and evidence.", - package_uri: `local://packages/token=build-${runId}.tar.zst`, - package_hash: `sha256:package-${runId}`, - artifact_hash_manifest: [ - { - artifact: "answer.md", - hash: `sha256:answer-${runId}`, - size_bytes: 128, - notes: "primary answer artifact" - } - ], - worker_attestation: "I confirm this packet follows the locked project guide.", - evidence_items: [ - { - type: "log", - label: "checker dry run", - uri: `s3://workstream-demo/reports/user@team-${runId}.log`, - hash: `sha256:evidence-${runId}`, - size_bytes: 256, - metadata: { command: "workstream-demo" } - } - ] - }; -} diff --git a/demos/week1_api_demo_ui/src/main.tsx b/demos/week1_api_demo_ui/src/main.tsx deleted file mode 100644 index 29260dd..0000000 --- a/demos/week1_api_demo_ui/src/main.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from "react"; -import { createRoot } from "react-dom/client"; - -import { App } from "./App"; -import "./styles.css"; - -createRoot(document.getElementById("root")!).render( - - - -); diff --git a/demos/week1_api_demo_ui/src/styles.css b/demos/week1_api_demo_ui/src/styles.css deleted file mode 100644 index 097da15..0000000 --- a/demos/week1_api_demo_ui/src/styles.css +++ /dev/null @@ -1,497 +0,0 @@ -:root { - color: #1e2528; - background: #eef1f0; - font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - font-synthesis: none; - text-rendering: optimizelegibility; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-width: 320px; - min-height: 100vh; -} - -button, -input { - font: inherit; -} - -button { - border: 0; - cursor: pointer; -} - -button:disabled { - cursor: not-allowed; - opacity: 0.55; -} - -.app-shell { - display: grid; - grid-template-columns: 320px minmax(0, 1fr); - min-height: 100vh; -} - -.sidebar { - background: #162023; - color: #f4f7f6; - padding: 24px; - display: flex; - flex-direction: column; - gap: 18px; -} - -.brand-block { - display: flex; - align-items: center; - gap: 14px; - padding-bottom: 8px; -} - -.brand-mark { - display: grid; - place-items: center; - width: 44px; - height: 44px; - background: #39a98b; - color: #071412; - border-radius: 8px; - font-weight: 800; - font-size: 22px; -} - -.brand-block h1, -.topbar h2, -.section-heading h3 { - margin: 0; -} - -.brand-block h1 { - font-size: 22px; - line-height: 1.1; -} - -.brand-block p, -.topbar p { - margin: 5px 0 0; - color: #9fb2b2; -} - -.panel { - background: #ffffff; - border: 1px solid #dce4e1; - border-radius: 8px; - box-shadow: 0 16px 40px rgba(30, 37, 40, 0.06); -} - -.sidebar .panel { - background: rgba(255, 255, 255, 0.06); - border-color: rgba(255, 255, 255, 0.12); - box-shadow: none; -} - -.compact { - padding: 16px; -} - -.panel-title { - display: flex; - align-items: center; - gap: 8px; - font-weight: 700; - margin-bottom: 14px; -} - -label { - display: grid; - gap: 6px; - color: #bfd0d0; - font-size: 12px; - margin-top: 12px; -} - -input { - width: 100%; - border: 1px solid rgba(255, 255, 255, 0.14); - background: rgba(5, 12, 14, 0.4); - color: #f8fbfa; - border-radius: 6px; - padding: 10px 11px; - outline: none; -} - -input:focus { - border-color: #39a98b; -} - -.actor-row { - display: flex; - justify-content: space-between; - gap: 10px; - align-items: center; - padding: 9px 0; - border-top: 1px solid rgba(255, 255, 255, 0.1); -} - -.actor-row:first-of-type { - border-top: 0; -} - -code { - font-size: 12px; - color: #435154; - background: #eef4f1; - padding: 3px 6px; - border-radius: 5px; - white-space: nowrap; -} - -.sidebar code { - color: #8fe0c9; - background: rgba(57, 169, 139, 0.12); -} - -.workspace { - min-width: 0; - padding: 28px; -} - -.topbar { - display: flex; - justify-content: space-between; - gap: 24px; - align-items: flex-start; - margin-bottom: 20px; -} - -.topbar h2 { - font-size: 26px; - letter-spacing: 0; -} - -.actions { - display: flex; - gap: 10px; -} - -.primary, -.secondary, -.icon-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - border-radius: 7px; - height: 40px; - padding: 0 14px; - font-weight: 700; -} - -.primary { - color: #071412; - background: #39a98b; -} - -.secondary, -.icon-button { - color: #253033; - background: #ffffff; - border: 1px solid #d8e0dd; -} - -.icon-button { - width: 36px; - height: 36px; - padding: 0; -} - -.error-banner { - border: 1px solid #f0b6a7; - background: #fff1ed; - color: #9f321f; - border-radius: 8px; - padding: 12px 14px; - margin-bottom: 16px; -} - -.metric-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 14px; - margin-bottom: 18px; -} - -.metric-card { - background: #ffffff; - border: 1px solid #dce4e1; - border-radius: 8px; - padding: 16px; - display: grid; - gap: 8px; -} - -.metric-card span { - color: #6c7c80; - font-size: 12px; - font-weight: 700; - text-transform: uppercase; -} - -.metric-card strong { - font-size: 22px; - line-height: 1; -} - -.metric-card em { - width: fit-content; - border-radius: 999px; - padding: 4px 8px; - font-size: 12px; - font-style: normal; -} - -.metric-card em.good { - background: #daf4ea; - color: #11664f; -} - -.metric-card em.info { - background: #ddeffd; - color: #205c89; -} - -.metric-card em.muted { - background: #eef1f0; - color: #6c7c80; -} - -.content-grid { - display: grid; - grid-template-columns: minmax(360px, 0.9fr) minmax(420px, 1.1fr); - grid-template-rows: minmax(420px, auto) minmax(260px, auto); - gap: 18px; -} - -.flow-panel, -.record-panel, -.event-panel { - padding: 18px; -} - -.flow-panel { - grid-row: 1 / span 2; -} - -.section-heading { - display: flex; - justify-content: space-between; - gap: 14px; - align-items: center; - margin-bottom: 16px; -} - -.section-heading h3 { - font-size: 16px; -} - -.section-heading span { - color: #708084; - font-size: 13px; -} - -.step-list, -.event-list { - display: grid; - gap: 10px; -} - -.step-row { - display: grid; - grid-template-columns: 38px minmax(0, 1fr) 36px; - gap: 12px; - align-items: center; - padding: 12px; - border: 1px solid #dde5e2; - border-radius: 8px; - background: #fbfcfc; -} - -.step-icon { - display: grid; - place-items: center; - width: 38px; - height: 38px; - border-radius: 8px; - background: #e8f6f1; - color: #198069; -} - -.step-icon.running { - background: #fff4cf; - color: #a36a00; -} - -.step-copy { - min-width: 0; - display: grid; - gap: 4px; -} - -.step-copy strong, -.event-row strong { - font-size: 14px; -} - -.step-copy span, -.event-row span { - color: #708084; - font-size: 13px; - line-height: 1.35; -} - -.event-request { - display: flex; - gap: 5px; - align-items: baseline; - min-width: 0; -} - -.event-request b { - color: #526368; - font-size: 11px; -} - -.event-request span { - overflow-wrap: anywhere; -} - -.tab-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 12px; -} - -.tab-row button { - height: 32px; - border-radius: 6px; - padding: 0 10px; - background: #eef1f0; - color: #4d5e61; - font-weight: 700; -} - -.tab-row button.active { - background: #162023; - color: #ffffff; -} - -pre { - overflow: auto; - min-height: 310px; - max-height: 530px; - margin: 0; - padding: 14px; - border-radius: 8px; - background: #101719; - color: #d9f3eb; - font-size: 12px; - line-height: 1.5; -} - -.event-row { - display: grid; - grid-template-columns: 34px minmax(0, 1fr) auto; - gap: 12px; - align-items: center; - padding: 10px 0; - border-top: 1px solid #e6ecea; -} - -.event-row:first-of-type { - border-top: 0; -} - -.event-status { - display: grid; - place-items: center; - width: 30px; - height: 30px; - border-radius: 50%; - font-size: 11px; - font-weight: 800; -} - -.event-status.ok { - background: #daf4ea; - color: #11664f; -} - -.event-status.bad { - background: #ffe2dc; - color: #9f321f; -} - -.empty-state { - color: #708084; - padding: 20px 0; -} - -@media (max-width: 1120px) { - .app-shell { - grid-template-columns: 1fr; - } - - .sidebar { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .brand-block { - align-items: flex-start; - } -} - -@media (max-width: 860px) { - .workspace, - .sidebar { - padding: 18px; - } - - .sidebar, - .metric-grid, - .content-grid { - grid-template-columns: 1fr; - } - - .flow-panel { - grid-row: auto; - } - - .topbar { - flex-direction: column; - } - - .actions { - width: 100%; - } - - .actions button { - flex: 1; - } - - .event-row { - grid-template-columns: 34px minmax(0, 1fr); - } - - .event-row code { - grid-column: 2; - width: fit-content; - } -} diff --git a/demos/week1_api_demo_ui/tsconfig.app.json b/demos/week1_api_demo_ui/tsconfig.app.json deleted file mode 100644 index 7a7a882..0000000 --- a/demos/week1_api_demo_ui/tsconfig.app.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2022", - "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "allowJs": false, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "Bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx" - }, - "include": ["src"] -} diff --git a/demos/week1_api_demo_ui/tsconfig.json b/demos/week1_api_demo_ui/tsconfig.json deleted file mode 100644 index 426eda2..0000000 --- a/demos/week1_api_demo_ui/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" } - ] -} diff --git a/demos/week1_api_demo_ui/vite.config.ts b/demos/week1_api_demo_ui/vite.config.ts deleted file mode 100644 index f7d2ef6..0000000 --- a/demos/week1_api_demo_ui/vite.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; - -export default defineConfig({ - plugins: [react()], - server: { - port: 5173, - proxy: { - "/api": "http://127.0.0.1:8000", - "/health": "http://127.0.0.1:8000" - } - } -}); diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 8022592..3c4ab9a 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -64,9 +64,11 @@ must not be treated as the primary identity. Workstream keeps `ActorIdentity` rows for local workflow continuity, audit display, profile linkage, assignments, and later reputation records. It does -not own password authentication, primary login sessions, or role authority. -Route authorization still uses the verified token roles in the current -`ActorContext`. +not own password authentication or primary login sessions. Workstream does own +product roles and exact resource authorization keyed by issuer plus subject. In +the v0.1 bootstrap, route checks may still read trusted role claims from the +current `ActorContext` until the dedicated Workstream role-assignment API is +introduced. `ActorProfile` is the shared profile and eligibility model attached to an `ActorIdentity`. @@ -93,9 +95,9 @@ Initial profile types: - project_owner Profile rows are metadata and workflow eligibility records. They do not grant -route access without the matching verified token role. A project owner profile -is scoped source/contact metadata and is not the same as a project manager -permission role. +route access and are not the canonical Workstream role-assignment table. A +project owner profile is scoped source/contact metadata and is not the same as +a project manager permission role. Initial profile statuses: @@ -106,8 +108,9 @@ Initial profile statuses: - `disabled`: retained for audit but blocked from workflow eligibility. Auth observation alone may create `observed` profiles, but it must not mark a -worker or reviewer profile `active`. Route access always comes from the current -verified token roles, not from profile status. +worker or reviewer profile `active`. Profile status can satisfy workflow +eligibility only when product authorization for the current request has already +passed. `project_owner` is a scoped profile/contact relationship, not a route role. In this chunk it is created from trusted relationship claims when present. Later @@ -121,7 +124,7 @@ The trusted relationship claim key is `scope_id`, and optional object `profile_metadata`. These values are persisted as observed relationship metadata only; they are not route authorization. -Verified token roles: +Trusted v0.1 bootstrap request roles: - admin - project_manager diff --git a/docs/glossary.md b/docs/glossary.md index 7852cc3..f555f1b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -19,11 +19,15 @@ does not author or approve Workstream's machine-readable internal policy schema. ## ActorContext The trusted per-request actor object resolved from a verified Flow token. It -contains the current actor id, external subject, issuer, roles, claim snapshot, +contains the current actor id, external subject, issuer, scopes/roles when +present in the trusted request context, claim snapshot, auth source, and display metadata. The Flow issuer plus subject is the canonical portable identity anchor; Workstream's actor id is a local durable reference -derived from that pair. Route authorization uses this current token context, not -persisted profile rows. +derived from that pair. The Identity Issuer is not the source of truth for +Workstream product roles; Workstream owns exact resource authorization keyed by +issuer plus subject. In the v0.1 bootstrap, route checks may still read trusted +role claims from the current actor context until the Workstream role-assignment +API is introduced. Persisted profile rows are never route permission grants. ## ActorIdentity @@ -39,9 +43,9 @@ issuance, or global identity authority. Workstream's shared profile and workflow eligibility record attached to an `ActorIdentity`. Initial profile types include worker, reviewer, admin, project_manager, and project_owner. A profile can store status, skill tags, -scope, and metadata, but it does not grant route access without the matching -verified token role. A project_owner profile is scoped source/contact metadata, -not project-manager authority. +scope, and metadata, but it is not the canonical role-assignment table and does +not grant route access. A project_owner profile is scoped source/contact +metadata, not project-manager authority. `observed` profile status is audit/display metadata from verified token observation. `active` profile status means an explicit profile workflow made diff --git a/docs/operations_roles_permissions.md b/docs/operations_roles_permissions.md index 379e878..d989487 100644 --- a/docs/operations_roles_permissions.md +++ b/docs/operations_roles_permissions.md @@ -6,11 +6,21 @@ Workstream needs explicit permissions from the first version because review, pay ## Roles -Route authorization roles come from trusted Flow token claims resolved into the -current `ActorContext`. Local Workstream `ActorIdentity` and `ActorProfile` -records may mirror observed roles, profile state, skill tags, scope, and -eligibility metadata, but persisted profile rows do not grant route access by -themselves. +The Identity Issuer owns identity, audience, scopes, delegation, and token +signing. Workstream owns product roles and exact resource authorization keyed +by the issuer plus subject. Scopes are an outer request-class gate; they are not +proof that the subject is a reviewer, worker, project owner, or admin inside +Workstream. + +In the current v0.1 bootstrap, route checks still read trusted role claims from +the verified `ActorContext` where a dedicated Workstream role-assignment table +does not exist yet. Those token roles are request context and provisioning +input, not the long-term source of truth. The role-assignment API must become a +Workstream-owned authorization layer keyed to `ActorIdentity`. + +Local Workstream `ActorIdentity` and `ActorProfile` records may mirror observed +roles, profile state, skill tags, scope, and eligibility metadata, but persisted +profile rows do not grant route access by themselves. | Role | Purpose | | --- | --- | @@ -66,6 +76,34 @@ the route still requires the matching role in the current verified token. audited override path. It must not masquerade as worker task claiming. - Admin overrides must create an audit event with reason and evidence. +## Role Provisioning Direction + +The Workstream role/provisioning layer is product-owned. A subject can exist in +the Identity Issuer and still have no Workstream access until Workstream creates +local actor and role records for that issuer plus subject. + +The first durable shape should be: + +```text +WorkstreamActor +- id +- issuer +- subject +- status + +WorkstreamRoleAssignment +- actor_id +- role +- scope_type +- scope_id +- status +``` + +Role records may be global, project-scoped, task-scoped, or review-queue scoped. +One actor can hold multiple roles at once, such as worker plus reviewer, or +admin plus worker. Permission checks must evaluate the current role assignment +for the exact resource and still enforce workflow rules such as no self-review. + ## First-Version Enforcement The first version enforces permissions in application service or policy code, diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index f17a556..a265bec 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -35,7 +35,7 @@ Current phase: Week 3 review and revision preparation. - Chunk 3 project guide foundation with checker, review, revision, and payment policy context. - Chunk 4 task queue, worker/reviewer profiles, assignment, claim, start, and task audit events. - Chunk 5 submission packet foundation with evidence items, versioning, server-stamped locked context, and submission locking. -- Week 1 backend dry run through `Project -> Guide -> Task -> Screening -> Ready -> Claim -> Start -> Submit -> Lock submission`. +- Backend API contract drill through `Project -> Guide -> Task -> Screening -> Ready -> Claim -> Start -> Submit -> Lock submission`. - Week 2 checker framework scope specification. - Chunk 6 checker contract and records specification. - Chunk 7 checker runner, registry, structural checkers, durable checker records, and API tests. @@ -70,12 +70,12 @@ Current phase: Week 3 review and revision preparation. - Confirm who owns product, engineering, review, operations, and payment reconciliation during the first build cycle. - Confirm the first v0.1 project guide uses the locked guide fields, task contract fields, evidence IDs, and contribution record flow. -## Week 1 Dry Run +## Backend API Contract Drill Run from the backend directory against local Postgres: ```bash -WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/week1_dry_run.py +WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py ``` The script runs migrations forward and exercises: @@ -105,7 +105,7 @@ The Week 2 closeout gate is deterministic and must fail on contract drift. Required invariants: -- Week 1 and Week 2 real API drills run only against local `postgresql+asyncpg://` test databases named `workstream_test` or `test_workstream` unless an explicit write-risk override is supplied. +- Real API drills run only against local `postgresql+asyncpg://` test databases named `workstream_test` or `test_workstream` unless an explicit write-risk override is supplied. - Pre-submit checker responses are authoritative for submission intake and must not create submissions, checker runs, or lifecycle transitions when blocking failures exist. - Missing or unexpected pre-submit checker names fail the drill. - Missing or unexpected durable checker names fail the drill. @@ -119,7 +119,7 @@ Week 2 closeout validation is not only this script. The full gate is: ```bash .venv/bin/python -m ruff check app tests scripts -WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/week1_api_e2e.py +WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/week2_api_e2e.py WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python -m pytest tests/test_checkers.py tests/test_tasks.py -q WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python -m pytest -q diff --git a/examples/terminal_benchmark/terminal_benchmark_api_e2e.py b/examples/terminal_benchmark/terminal_benchmark_api_e2e.py index 2b91dfa..d17b7c1 100644 --- a/examples/terminal_benchmark/terminal_benchmark_api_e2e.py +++ b/examples/terminal_benchmark/terminal_benchmark_api_e2e.py @@ -1,4 +1,4 @@ -"""Run real Terminal Benchmark source material through the Week 1 and Week 2 APIs. +"""Run real Terminal Benchmark source material through the current API contracts. This is an example drill, not Workstream runtime code and not a required CI test. It expects a local reviewer source-material path through @@ -36,7 +36,7 @@ from app.db import session as db_session from app.modules.checkers.models import CheckerResult, CheckerRun from app.modules.tasks.models import AuditEvent, EvidenceItem, Submission, WorkstreamTask -from week1_api_e2e import ( +from api_contract_e2e import ( alembic_config, api_environment, find_free_port, @@ -988,7 +988,7 @@ async def assert_database_invariants(scenarios: list[dict]) -> None: async def exercise_terminal_benchmark_api(base_url: str, env: dict[str, str]) -> None: - """Run the Terminal Benchmark fixture through Week 1 and Week 2 API behavior.""" + """Run the Terminal Benchmark fixture through current API behavior.""" fixture = load_fixture(fixture_root()) flow_issuer, flow_audience, flow_secret = flow_settings(env) run_id = uuid4().hex[:8] diff --git a/scripts/check_internal_review_evidence.py b/scripts/check_internal_review_evidence.py index d98912b..c0beb28 100644 --- a/scripts/check_internal_review_evidence.py +++ b/scripts/check_internal_review_evidence.py @@ -27,15 +27,12 @@ "backend/alembic/", "backend/app/", "backend/tests/", - "demos/week1_api_demo_ui/", "docs/", "scripts/", ) RELEVANT_EXACT_PATHS = { "backend/alembic.ini", "backend/pyproject.toml", - "demos/week1_api_demo_ui/package-lock.json", - "demos/week1_api_demo_ui/package.json", } IGNORED_PREFIXES = ( "docs/internal_reviews/", @@ -157,15 +154,7 @@ def add(track: str) -> None: add("architecture") if path.startswith((".github/", "scripts/")) or path in { "backend/pyproject.toml", - "demos/week1_api_demo_ui/package-lock.json", - "demos/week1_api_demo_ui/package.json", - } or ( - path.startswith("demos/week1_api_demo_ui/") - and ( - Path(path).name in {"vite.config.ts", "vite.config.js"} - or Path(path).name.startswith("tsconfig") - ) - ): + }: add("ci integrity") if path.endswith(".md") or path.startswith(("docs/", ".agent-loop/", ".agents/")) or path in { "AGENTS.md", @@ -174,11 +163,7 @@ def add(track: str) -> None: add("docs") if path.startswith((".agents/skills/", ".codex/agents/", "backend/app/", "scripts/")): add("reuse/dedup") - if ( - path.startswith(("backend/tests/", "demos/week1_api_demo_ui/")) - or "/tests/" in path - or Path(path).name.startswith("test_") - ): + if path.startswith("backend/tests/") or "/tests/" in path or Path(path).name.startswith("test_"): add("test delta") return tuple(required) diff --git a/scripts/test_agent_gates.py b/scripts/test_agent_gates.py index d19e5f2..ec32fd8 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -59,8 +59,6 @@ def test_backend_config_paths_require_review_evidence() -> None: assert gate.is_relevant("backend/alembic/versions/0001_init.py") assert gate.is_relevant("backend/alembic.ini") assert gate.is_relevant("backend/pyproject.toml") - assert gate.is_relevant("demos/week1_api_demo_ui/package.json") - assert gate.is_relevant("demos/week1_api_demo_ui/src/App.tsx") backend_tracks = gate.required_tracks_for(["backend/alembic/versions/0001_init.py"]) assert "architecture" in backend_tracks @@ -69,14 +67,6 @@ def test_backend_config_paths_require_review_evidence() -> None: backend_config_tracks = gate.required_tracks_for(["backend/pyproject.toml"]) assert "ci integrity" in backend_config_tracks - demo_source_tracks = gate.required_tracks_for(["demos/week1_api_demo_ui/src/App.tsx"]) - assert "test delta" in demo_source_tracks - assert "ci integrity" not in demo_source_tracks - - demo_config_tracks = gate.required_tracks_for(["demos/week1_api_demo_ui/vite.config.ts"]) - assert "test delta" in demo_config_tracks - assert "ci integrity" in demo_config_tracks - def test_review_evidence_files_are_not_relevant_changes() -> None: """Review evidence files satisfy the gate without requiring more evidence.""" From ef805bd2eaf8dc467297c06ee75adace8be7a640 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 19:25:46 +0100 Subject: [PATCH 06/25] Align actor registry contract after review --- .../CHUNK_MAP.md | 55 +++++++++------- ...-001-11-actor-identity-profile-registry.md | 30 +++++---- backend/tests/test_actors.py | 64 +++++++++++++++++++ backend/tests/test_auth.py | 33 ++++++++++ .../workstream_architecture_brief.md | 2 +- docs/architecture_data_model.md | 6 ++ docs/diagrams/workstream_v01_container.md | 2 +- ...026-06-12_week2_closeout_real_api_drill.md | 2 +- ...-13_week1_week2_deterministic_hardening.md | 4 +- docs/roadmap_day_by_day_execution_plan.md | 6 +- docs/spec_week2_checker_framework.md | 4 +- 11 files changed, 161 insertions(+), 47 deletions(-) diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/CHUNK_MAP.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/CHUNK_MAP.md index 82a7fad..6f36a9a 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/CHUNK_MAP.md @@ -50,7 +50,7 @@ backend/app/modules/projects/** backend/tests/test_projects.py backend/tests/test_tasks.py backend/tests/test_checkers.py -backend/scripts/week1_api_e2e.py +backend/scripts/api_contract_e2e.py docs/architecture_data_model.md docs/decision_0011_submission_artifact_policy_drives_pre_submit.md docs/operations_project_operating_manual.md @@ -166,7 +166,7 @@ backend/app/modules/checkers/** backend/tests/test_projects.py backend/tests/test_checkers.py backend/tests/test_tasks.py -backend/scripts/week1_api_e2e.py +backend/scripts/api_contract_e2e.py README.md docs/architecture_checker_framework.md docs/architecture_data_model.md @@ -501,7 +501,6 @@ Allowed files: ```text examples/terminal_benchmark/** backend/app/adapters/project_agents/openai_agent_sdk.py -backend/app/adapters/project_agents/local_fixture.py backend/app/interfaces/project_agents.py backend/app/modules/projects/models.py backend/app/modules/projects/schemas.py @@ -512,7 +511,7 @@ backend/tests/test_checkers.py backend/tests/test_projects.py backend/tests/test_tasks.py backend/tests/test_alembic.py -backend/scripts/week1_api_e2e.py +backend/scripts/api_contract_e2e.py README.md docs/architecture_lockdown.md docs/architecture_data_model.md @@ -537,7 +536,7 @@ Not allowed: backend/app/** except listed adapter/interface/project/task files backend/alembic/** except `backend/alembic/versions/0010_remove_legacy_project_guide_fields.py` backend/tests/** except `backend/tests/test_projects.py`, `backend/tests/test_tasks.py`, `backend/tests/test_checkers.py`, and `backend/tests/test_alembic.py` -backend/scripts/** except `backend/scripts/week1_api_e2e.py` +backend/scripts/** except `backend/scripts/api_contract_e2e.py` .github/workflows/** demos/** frontend/** @@ -615,7 +614,7 @@ backend/alembic/versions/** backend/app/modules/tasks/** backend/tests/test_tasks.py backend/tests/test_alembic.py -backend/scripts/week1_dry_run.py +backend/scripts/api_contract_e2e.py backend/scripts/week2_api_e2e.py docs/architecture_data_model.md docs/operations_project_operating_manual.md @@ -727,11 +726,11 @@ Acceptance criteria: fixture runtime. - Tests use explicit test-local fakes for deterministic project-agent behavior. - Terminal Benchmark example docs and script do not describe a removed runtime - selector or fallback. + selector, fixture runtime, or fallback. - README explains that automatic project setup needs OpenAI Agents SDK model and API-key settings. -- Temporary Week 1 demo startup does not enable setup autostart without the - required OpenAI worker configuration. +- Local API contract drills do not enable setup autostart without the required + OpenAI worker configuration. Verification: @@ -801,10 +800,11 @@ Allowed files: ```text backend/alembic/versions/*_actor_identity_profile_registry.py +.github/workflows/backend.yml backend/app/api/router.py backend/app/api/deps/auth.py -backend/app/api/routes/demo.py backend/app/api/routes/auth.py +backend/app/core/config.py backend/app/db/models.py backend/app/modules/actors/__init__.py backend/app/modules/actors/models.py @@ -820,17 +820,23 @@ backend/tests/test_actors.py backend/tests/test_alembic.py backend/tests/test_auth.py backend/tests/test_tasks.py -backend/scripts/week1_dry_run.py -backend/scripts/week1_api_e2e.py +backend/scripts/api_contract_e2e.py backend/scripts/week2_api_e2e.py examples/terminal_benchmark/terminal_benchmark_api_e2e.py +README.md docs/architecture_data_model.md +docs/architecture_brief/workstream_architecture_brief.md docs/architecture_lockdown.md docs/architecture_system_architecture.md +docs/diagrams/workstream_v01_container.md docs/glossary.md docs/operations_roles_permissions.md +docs/roadmap_day_by_day_execution_plan.md +docs/roadmap_status.md docs/spec_chunk_2_auth_actor_boundary.md docs/spec_chunk_4_task_queue_assignment.md +scripts/check_internal_review_evidence.py +scripts/test_agent_gates.py .agent-loop/LOOP_STATE.md .agent-loop/WORK_QUEUE.md .agent-loop/REVIEW_LOG.md @@ -854,11 +860,12 @@ Acceptance criteria: - `ActorIdentity` and `ActorProfile` models/tables exist with unique identity and profile constraints. -- Existing worker/reviewer profile rows are backfilled into the shared profile - model and the separate profile tables, ORM models, and repository authority - paths stop owning profile state. -- Seeded migration tests prove worker/reviewer profile backfill preserves - profile type, status, skill tags, scope, and table removal. +- The separate worker/reviewer profile tables, ORM models, and repository + authority paths stop owning profile state. This build-phase migration does + not preserve obsolete experimental profile rows through a compatibility + backfill. +- Migration tests prove the current schema exposes the shared profile authority + and old worker/reviewer profile tables are absent. - Flow token verification remains pure. Actor registration uses a separate actors service/repository boundary. - Route authorization remains token-derived. @@ -871,13 +878,13 @@ Acceptance criteria: profile. - Stored profiles without matching token roles do not grant operator, worker, reviewer, or project-manager access. -- Stale backend demo/script imports of old worker/reviewer profile models are - removed, rewired, or retired so the shared actor profile model is the only - profile authority. -- Stale backend script/example calls to `/api/v1/demo/worker-profile` are - removed, rewired to `POST /api/v1/workers/me/profile`, or explicitly retired. -- Verification includes a stale helper scan for old profile model imports and - the removed demo worker-profile endpoint. +- Stale backend script imports of old worker/reviewer profile models are + removed or retired so the shared actor profile model is the only profile + authority. +- Stale backend script/example calls to local demo profile endpoints are + removed; current drills use `POST /api/v1/workers/me/profile`. +- Verification includes a stale helper scan for old profile model imports, + removed Week 1 entry points, and removed demo worker-profile endpoints. Required reviewers: diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md index 1e33fb0..61d1441 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md @@ -90,10 +90,13 @@ examples/terminal_benchmark/terminal_benchmark_api_e2e.py examples/terminal_benchmark/LOCAL_VALIDATION_NOTES.md README.md docs/architecture_data_model.md +docs/architecture_brief/workstream_architecture_brief.md docs/architecture_lockdown.md docs/architecture_system_architecture.md +docs/diagrams/workstream_v01_container.md docs/glossary.md docs/operations_roles_permissions.md +docs/roadmap_day_by_day_execution_plan.md docs/roadmap_status.md docs/spec_chunk_2_auth_actor_boundary.md docs/spec_chunk_4_task_queue_assignment.md @@ -124,11 +127,10 @@ blockchain, ERC-8004, ERC-8183, settlement, marketplace, or external source adap ``` Backend script/example edits are allowed only to remove, retire, or rewire -stale `WorkerProfile`/`ReviewerProfile` imports and stale demo/profile calls -that would break application import or local evidence scripts after the shared -actor profile migration. Stale demo routes, demo workflow files, and obsolete -Week 1 script entry points should be removed rather than kept as compatibility -surfaces. +stale per-role profile imports and stale demo/profile calls that would break +application import or local evidence scripts after the shared actor profile +migration. Stale demo routes, demo workflow files, and obsolete script entry +points should be removed rather than kept as compatibility surfaces. ## Expected design @@ -341,8 +343,11 @@ current contract and drops the old profile tables. access from the stored row. - [ ] `GET /api/v1/auth/me` proves actor identity/profile persistence without adding any Workstream-owned auth session behavior. -- [ ] Repeated requests by the same external issuer/subject update last-seen - identity/profile metadata without creating duplicate actor rows. +- [ ] Repeated requests by the same external issuer/subject never create + duplicate actor rows. Registry writes may be skipped inside the configured + freshness window when trusted claims and required observed profiles already + match; stale or disabled freshness windows refresh identity last-seen + metadata. - [ ] Parametrized tests cover profile creation/refresh for `worker`, `reviewer`, `admin`, and `project_manager` from trusted token roles, plus scoped `project_owner` profile creation from trusted project @@ -385,17 +390,17 @@ current contract and drops the old profile tables. `ActorProfile`, route authorization, and workflow eligibility. - [ ] The next Terminal Benchmark live API drill must use `POST /api/v1/workers/me/profile` for worker profile setup. -- [ ] Stale backend evidence helpers no longer import or write old - `WorkerProfile` or `ReviewerProfile` models after those stores are - removed. They are rewired to `ActorProfile` or explicitly retired. +- [ ] Stale backend evidence helpers no longer import or write old per-role + profile models after those stores are removed. They are rewired to + `ActorProfile` or explicitly retired. - [ ] `backend/scripts/api_contract_e2e.py`, `backend/scripts/week2_api_e2e.py`, and `examples/terminal_benchmark/terminal_benchmark_api_e2e.py` use `POST /api/v1/workers/me/profile` or are explicitly retired. - [ ] Terminal Benchmark validation notes identify the canonical worker profile API and do not describe worker profile setup as an obsolete bootstrap path. -- [ ] The obsolete Week 1 demo UI, local demo route, and deleted script entry - points are not kept as compatibility surfaces. +- [ ] The obsolete demo UI, local demo route, and deleted script entry points + are not kept as compatibility surfaces. ## Verification commands @@ -404,7 +409,6 @@ cd backend && .venv/bin/python -m ruff check app/api/deps/auth.py app/api/routes cd backend && .venv/bin/python -m pytest tests/test_alembic.py tests/test_actors.py tests/test_auth.py -q cd backend && .venv/bin/python -m pytest tests/test_tasks.py -q cd backend && .venv/bin/docstr-coverage app/api app/modules/actors app/modules/tasks --config .docstr.yaml -! rg -n 'WorkerProfile|ReviewerProfile|/api/v1/demo/worker-profile|week1_api_e2e|week1_dry_run|week1_api_demo_ui|WORKSTREAM_ENABLE_DEMO_ROUTES' backend/scripts examples/terminal_benchmark backend/app README.md docs/roadmap_status.md scripts python3 scripts/check_stale_workstream_wording.py python3 scripts/check_markdown_links.py git diff --check diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index 027da75..e7f96bc 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -187,6 +187,70 @@ async def test_repeated_auth_me_does_not_rewrite_unchanged_observed_profile( assert profile_rows[0].updated_at == stale_time +async def test_auth_me_refreshes_identity_after_configured_interval( + actor_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + set_dev_actor(monkeypatch, roles="worker", subject="stale-identity-worker") + created = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) + assert created.status_code == 200, created.text + stale_time = datetime.now(UTC) - timedelta(minutes=10) + async with db_session.get_session_factory()() as session: + identity = await session.scalar( + select(ActorIdentity).where( + ActorIdentity.actor_id == actor_id("stale-identity-worker") + ) + ) + assert identity is not None + identity.last_seen_at = stale_time + await session.commit() + + refreshed = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) + assert refreshed.status_code == 200, refreshed.text + async with db_session.get_session_factory()() as session: + identity = await session.scalar( + select(ActorIdentity).where( + ActorIdentity.actor_id == actor_id("stale-identity-worker") + ) + ) + + assert identity is not None + assert identity.last_seen_at > stale_time + + +async def test_zero_registry_refresh_interval_writes_identity_every_time( + actor_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("WORKSTREAM_ACTOR_REGISTRY_REFRESH_INTERVAL_SECONDS", "0") + get_settings.cache_clear() + set_dev_actor(monkeypatch, roles="worker", subject="always-refresh-worker") + first = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) + assert first.status_code == 200, first.text + stale_time = datetime.now(UTC) - timedelta(minutes=10) + async with db_session.get_session_factory()() as session: + identity = await session.scalar( + select(ActorIdentity).where( + ActorIdentity.actor_id == actor_id("always-refresh-worker") + ) + ) + assert identity is not None + identity.last_seen_at = stale_time + await session.commit() + + second = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) + assert second.status_code == 200, second.text + async with db_session.get_session_factory()() as session: + identity = await session.scalar( + select(ActorIdentity).where( + ActorIdentity.actor_id == actor_id("always-refresh-worker") + ) + ) + + assert identity is not None + assert identity.last_seen_at > stale_time + + @pytest.mark.parametrize("role", ["worker", "reviewer", "admin", "project_manager"]) async def test_token_roles_create_observed_non_eligibility_profiles( actor_client: AsyncClient, diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 8e46a3b..e25ae51 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -8,6 +8,7 @@ from alembic import command from alembic.config import Config from httpx import ASGITransport, AsyncClient +from sqlalchemy.exc import SQLAlchemyError from app.adapters.auth.dev import DevelopmentAuthVerifier from app.adapters.auth.flow import FlowAuthVerifier @@ -16,6 +17,7 @@ from app.db import session as db_session from app.interfaces.auth import AuthVerificationError from app.main import create_app +from app.modules.actors.service import ActorService def _application_paths(app) -> set[str]: @@ -132,6 +134,37 @@ async def test_valid_dev_token_resolves_actor_context( assert body["audit_context"]["is_dev_auth"] is True +async def test_auth_me_maps_actor_registry_failure_to_service_unavailable( + monkeypatch: pytest.MonkeyPatch, + auth_database_env: str, +) -> None: + monkeypatch.setenv("WORKSTREAM_AUTH_PROVIDER", "dev") + monkeypatch.setenv("WORKSTREAM_ENVIRONMENT", "local") + monkeypatch.setenv("WORKSTREAM_DEV_AUTH_TOKEN", "local-token") + monkeypatch.setenv("WORKSTREAM_DEV_AUTH_SUBJECT", "registry-failure-subject") + monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ISSUER", "flow-dev-issuer") + monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ROLES", "worker") + get_settings.cache_clear() + + async def fail_register_actor(self, actor): + raise SQLAlchemyError("registry unavailable") + + monkeypatch.setattr(ActorService, "register_actor", fail_register_actor) + app = create_app() + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.get( + "/api/v1/auth/me", + headers={"Authorization": "Bearer local-token"}, + ) + + assert response.status_code == 503 + assert response.json()["detail"] == "Actor registry unavailable" + + async def test_actor_id_uses_subject_and_issuer_not_email() -> None: first = Settings( environment="local", diff --git a/docs/architecture_brief/workstream_architecture_brief.md b/docs/architecture_brief/workstream_architecture_brief.md index 7b192cd..706df3c 100644 --- a/docs/architecture_brief/workstream_architecture_brief.md +++ b/docs/architecture_brief/workstream_architecture_brief.md @@ -68,7 +68,7 @@ The context diagram shows Workstream as one system inside the broader Flow ecosy ## C2: v0.1 Container View -The container view shows the first 30-day implementation. It is intentionally small: React + Vite for the planned internal operations UI, FastAPI for the backend, Postgres for records, a storage interface for artifacts, and an async checker/job boundary. The Week 1 API demo UI is only a walkthrough client for backend validation; it is not the canonical product frontend. +The container view shows the first 30-day implementation. It is intentionally small: React + Vite for the planned internal operations UI, FastAPI for the backend, Postgres for records, a storage interface for artifacts, and an async checker/job boundary.
Workstream v0.1 container diagram diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 3c4ab9a..799f574 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -70,6 +70,12 @@ the v0.1 bootstrap, route checks may still read trusted role claims from the current `ActorContext` until the dedicated Workstream role-assignment API is introduced. +Actor registry refresh is bounded by +`WORKSTREAM_ACTOR_REGISTRY_REFRESH_INTERVAL_SECONDS`. Workstream verifies the +token on every protected request, but it may skip the local identity/profile +write when the stored identity is fresh, claims match, and required observed +profiles already exist. Setting the interval to `0` disables the skip path. + `ActorProfile` is the shared profile and eligibility model attached to an `ActorIdentity`. diff --git a/docs/diagrams/workstream_v01_container.md b/docs/diagrams/workstream_v01_container.md index 4ec4d28..032e7f3 100644 --- a/docs/diagrams/workstream_v01_container.md +++ b/docs/diagrams/workstream_v01_container.md @@ -12,7 +12,7 @@ Source: [workstream_v01_container.puml](workstream_v01_container.puml) | Container | Responsibility | | --- | --- | -| React + Vite operations UI | Planned internal project, queue, task, submission, review, payment, and reputation operations surfaces. The Week 1 API demo UI is separate and lives under `demos/`. | +| React + Vite operations UI | Planned internal project, queue, task, submission, review, payment, and reputation operations surfaces. | | FastAPI backend | API contracts, workflow rules, auth dependency, lifecycle guards, module orchestration, audit writes. | | Postgres | Record database for workflow state, policy context, submissions, checks, reviews, revisions, contribution records, payment records, reputation events, and audit history. | | Storage interface | Stable file/evidence boundary that can use local storage in development and R2/S3-style object storage later. | diff --git a/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md b/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md index 268b245..1fd306a 100644 --- a/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md +++ b/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md @@ -105,7 +105,7 @@ cd backend && .venv/bin/python -m ruff check app tests scripts Passed: ```bash -cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/week1_api_e2e.py +cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py ``` Passed: diff --git a/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md b/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md index d87a866..52def0d 100644 --- a/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md +++ b/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md @@ -117,10 +117,10 @@ Result: `All checks passed!` Passed: ```bash -cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/week1_api_e2e.py +cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py ``` -Result: `Week 1 real API e2e passed` and `PASS Week 1 database invariants`. +Result: `API contract e2e passed` and `PASS API contract database invariants`. Passed: diff --git a/docs/roadmap_day_by_day_execution_plan.md b/docs/roadmap_day_by_day_execution_plan.md index 93c83e5..1f73779 100644 --- a/docs/roadmap_day_by_day_execution_plan.md +++ b/docs/roadmap_day_by_day_execution_plan.md @@ -95,9 +95,9 @@ Exit criteria: Deliver: -- `WorkerProfile` -- `ReviewerProfile` -- `AdminRole` +- `ActorIdentity` +- `ActorProfile` +- Workstream-owned role assignment direction - assignment/claim flow - skill tags - external Flow actor identity mapped into Workstream actor context diff --git a/docs/spec_week2_checker_framework.md b/docs/spec_week2_checker_framework.md index a3d2c9e..6f82262 100644 --- a/docs/spec_week2_checker_framework.md +++ b/docs/spec_week2_checker_framework.md @@ -137,8 +137,8 @@ Checker results are exposed through backend contracts and operational output: - checker run API responses - checker result API responses - task/submission API expansion where needed -- dry-run scripts -- Week 1 API demo/debug output when useful +- backend API contract drills +- operational debug output when useful Week 2 does not build the product frontend page for checker results. The planned React + Vite operations dashboard consumes these backend contracts later. From eb1c523b04d55995a9a5069addfca2fc78672aa1 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 19:50:48 +0100 Subject: [PATCH 07/25] Remove stale checker and claim metadata surfaces --- backend/app/modules/actors/__init__.py | 1 - backend/app/modules/actors/service.py | 9 +-------- backend/tests/test_actors.py | 7 +++++-- .../terminal_benchmark/terminal_benchmark_api_e2e.py | 6 +++--- 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/backend/app/modules/actors/__init__.py b/backend/app/modules/actors/__init__.py index dd1e6d9..76dc3e7 100644 --- a/backend/app/modules/actors/__init__.py +++ b/backend/app/modules/actors/__init__.py @@ -1,2 +1 @@ """Actor identity and profile registry module.""" - diff --git a/backend/app/modules/actors/service.py b/backend/app/modules/actors/service.py index 66c0b3c..96710d3 100644 --- a/backend/app/modules/actors/service.py +++ b/backend/app/modules/actors/service.py @@ -431,14 +431,7 @@ def _trusted_relationship_profiles(self, actor: ActorContext) -> list[dict]: "profile_type": "project_owner", "scope_type": scope_type, "scope_id": scope_id, - "profile_metadata": { - "source": "trusted_relationship_claim", - **( - raw_profile.get("profile_metadata") - if isinstance(raw_profile.get("profile_metadata"), dict) - else {} - ), - }, + "profile_metadata": {"source": "trusted_relationship_claim"}, } ) return profiles diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index e7f96bc..c6241c9 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -401,7 +401,10 @@ async def test_scoped_project_owner_profile_comes_from_trusted_relationship_clai "profile_type": "project_owner", "scope_type": "project", "scope_id": "project-123", - "profile_metadata": {"organization": "Example Labs"}, + "profile_metadata": { + "organization": "Example Labs", + "api_key": "must-not-persist", + }, } ] }, @@ -425,7 +428,7 @@ async def test_scoped_project_owner_profile_comes_from_trusted_relationship_clai assert profiles[0].status == "observed" assert profiles[0].scope_type == "project" assert profiles[0].scope_id == "project-123" - assert profiles[0].profile_metadata["organization"] == "Example Labs" + assert profiles[0].profile_metadata == {"source": "trusted_relationship_claim"} async def test_active_profile_without_matching_token_role_cannot_use_worker_profile_api( diff --git a/examples/terminal_benchmark/terminal_benchmark_api_e2e.py b/examples/terminal_benchmark/terminal_benchmark_api_e2e.py index d17b7c1..e2b98e1 100644 --- a/examples/terminal_benchmark/terminal_benchmark_api_e2e.py +++ b/examples/terminal_benchmark/terminal_benchmark_api_e2e.py @@ -760,7 +760,7 @@ async def create_started_terminal_benchmark_task( ensure(screened["locked_guide_version"] == "v1", "screening did not lock guide v1") ensure( { - screened["locked_checker_policy_version"], + screened["locked_post_submit_checker_policy_version"], screened["locked_review_policy_version"], screened["locked_revision_policy_version"], screened["locked_payment_policy_version"], @@ -906,7 +906,7 @@ async def assert_database_invariants(scenarios: list[dict]) -> None: ensure( { task.locked_guide_version, - task.locked_checker_policy_version, + task.locked_post_submit_checker_policy_version, task.locked_review_policy_version, task.locked_revision_policy_version, task.locked_payment_policy_version, @@ -917,7 +917,7 @@ async def assert_database_invariants(scenarios: list[dict]) -> None: ensure( { checker_run.locked_guide_version, - checker_run.locked_checker_policy_version, + checker_run.locked_post_submit_checker_policy_version, checker_run.locked_review_policy_version, checker_run.locked_revision_policy_version, checker_run.locked_payment_policy_version, From d0fcad07440c1b83f3c8496a70773acc4e16dc86 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 19:55:03 +0100 Subject: [PATCH 08/25] Fix actor schema whitespace --- backend/app/modules/actors/schemas.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/modules/actors/schemas.py b/backend/app/modules/actors/schemas.py index 8954842..b8b867d 100644 --- a/backend/app/modules/actors/schemas.py +++ b/backend/app/modules/actors/schemas.py @@ -66,4 +66,3 @@ class ActorProfileResponse(BaseModel): email: str | None created_at: datetime updated_at: datetime - From f4487c5a466948847f0f01aa7e3bde22c3e4195d Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 21:07:44 +0100 Subject: [PATCH 09/25] Register actors on product routes safely --- backend/app/modules/actors/service.py | 4 +-- backend/app/modules/checkers/router.py | 10 +++--- backend/app/modules/projects/router.py | 30 ++++++++--------- backend/app/modules/tasks/router.py | 22 ++++++------- backend/app/schemas/auth.py | 41 +++++++++++++++++++++++- backend/tests/test_actors.py | 31 ++++++++++++++++++ backend/tests/test_projects.py | 32 ++++++++++++++++++ docs/architecture_data_model.md | 6 ++-- docs/operations_roles_permissions.md | 5 +-- docs/spec_chunk_2_auth_actor_boundary.md | 9 +++--- 10 files changed, 148 insertions(+), 42 deletions(-) diff --git a/backend/app/modules/actors/service.py b/backend/app/modules/actors/service.py index 96710d3..fa693b0 100644 --- a/backend/app/modules/actors/service.py +++ b/backend/app/modules/actors/service.py @@ -22,7 +22,7 @@ from app.modules.actors.schemas import ActorProfileActivationRequest, ActorProfileResponse from app.modules.tasks.models import AuditEvent from app.modules.tasks.repository import TaskRepository -from app.schemas.auth import ActorContext +from app.schemas.auth import ActorContext, sanitized_claim_snapshot TOKEN_OBSERVED_PROFILE_TYPES = {"worker", "reviewer", "admin", "project_manager"} @@ -367,7 +367,7 @@ def _identity_from_actor(self, actor: ActorContext) -> ActorIdentity: display_name=actor.display_name, email=actor.email, last_seen_roles=list(actor.roles), - last_claim_snapshot=actor.claim_snapshot, + last_claim_snapshot=sanitized_claim_snapshot(actor.claim_snapshot), auth_source=actor.auth_source, is_dev_auth=actor.is_dev_auth, ) diff --git a/backend/app/modules/checkers/router.py b/backend/app/modules/checkers/router.py index d9134d4..1066f19 100644 --- a/backend/app/modules/checkers/router.py +++ b/backend/app/modules/checkers/router.py @@ -9,7 +9,7 @@ from fastapi.responses import JSONResponse from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps.auth import get_current_actor +from app.api.deps.auth import get_registered_actor from app.core.permissions import PermissionDenied from app.db.session import get_db_session from app.modules.checkers.schemas import ( @@ -69,7 +69,7 @@ def checker_run_response(payload: CheckerRunResponse | list[CheckerRunResponse]) async def pre_submit_check( task_id: str, payload: PreSubmitCheckRequest, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> PreSubmitCheckResponse: """Return non-authoritative static checker feedback for a draft packet.""" @@ -89,7 +89,7 @@ async def pre_submit_check( async def run_submission_checkers( submission_id: str, payload: CheckerRunRequest, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> JSONResponse: """Trigger a durable internal checker run for a locked submission.""" @@ -122,7 +122,7 @@ async def run_submission_checkers( ) async def list_submission_checker_runs( submission_id: str, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> JSONResponse: """Return checker runs for one visible submission.""" @@ -142,7 +142,7 @@ async def list_submission_checker_runs( ) async def get_checker_run( checker_run_id: str, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> JSONResponse: """Return one visible checker run.""" diff --git a/backend/app/modules/projects/router.py b/backend/app/modules/projects/router.py index 76636a4..e1150be 100644 --- a/backend/app/modules/projects/router.py +++ b/backend/app/modules/projects/router.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps.auth import get_current_actor +from app.api.deps.auth import get_registered_actor from app.core.permissions import PermissionDenied from app.db.session import get_db_session from app.modules.projects.schemas import ( @@ -61,7 +61,7 @@ def permission_http_error(exc: PermissionDenied) -> HTTPException: @router.post("", response_model=ProjectResponse, status_code=201) async def create_project( payload: ProjectCreate, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> ProjectResponse: """Create a draft project shell for future guide versions.""" @@ -76,7 +76,7 @@ async def create_project( @router.get("/{project_id}", response_model=ProjectResponse) async def get_project( project_id: str, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> ProjectResponse: """Return one project by id.""" @@ -92,7 +92,7 @@ async def get_project( async def create_guide( project_id: str, payload: ProjectGuideCreate, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> ProjectGuideResponse: """Create a draft guide and enqueue automatic pre-submit setup.""" @@ -109,7 +109,7 @@ async def update_guide( project_id: str, guide_id: str, payload: ProjectGuideUpdate, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> ProjectGuideResponse: """Update a draft guide and optional post-submit/review/revision/payment policies.""" @@ -135,7 +135,7 @@ async def create_guide_source_snapshot( project_id: str, guide_id: str, payload: GuideSourceSnapshotCreate, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> GuideSourceSnapshotResponse: """Create an immutable source-material snapshot for a draft guide.""" @@ -161,7 +161,7 @@ async def create_guide_sufficiency_report( project_id: str, guide_id: str, payload: GuideSufficiencyReportCreate, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> GuideSufficiencyReportResponse: """Record Workstream's sufficiency assessment for a guide snapshot.""" @@ -194,7 +194,7 @@ async def run_guide_sufficiency_agent( guide_id: str, source_snapshot_id: str, response: Response, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> GuideSufficiencyReportResponse: """Run Workstream's guide sufficiency agent for a source snapshot.""" @@ -222,7 +222,7 @@ async def acknowledge_guide_sufficiency_warnings( guide_id: str, report_id: str, payload: GuideSufficiencyAcknowledgement, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> GuideSufficiencyReportResponse: """Acknowledge non-blocking guide sufficiency warnings.""" @@ -249,7 +249,7 @@ async def create_submission_artifact_policy( project_id: str, guide_id: str, payload: SubmissionArtifactPolicyCreate, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> SubmissionArtifactPolicyResponse: """Create a draft Workstream-derived submission artifact policy.""" @@ -282,7 +282,7 @@ async def run_submission_artifact_policy_derivation_agent( guide_id: str, source_snapshot_id: str, response: Response, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> SubmissionArtifactPolicyResponse: """Run Workstream's submission artifact policy derivation agent.""" @@ -310,7 +310,7 @@ async def update_submission_artifact_policy( guide_id: str, policy_id: str, payload: SubmissionArtifactPolicyUpdate, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> SubmissionArtifactPolicyResponse: """Update a draft submission artifact policy.""" @@ -337,7 +337,7 @@ async def approve_submission_artifact_policy( guide_id: str, policy_id: str, payload: SubmissionArtifactPolicyApprove, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> EffectiveProjectSubmissionArtifactPolicyResponse: """Approve a draft submission artifact policy and persist the effective policy.""" @@ -359,7 +359,7 @@ async def approve_submission_artifact_policy( async def activate_guide( project_id: str, guide_id: str, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> ActiveGuideResponse: """Activate a complete draft guide for a project.""" @@ -374,7 +374,7 @@ async def activate_guide( @router.get("/{project_id}/active-guide", response_model=ActiveGuideResponse) async def get_active_guide( project_id: str, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> ActiveGuideResponse: """Return the current active guide and policy context for a project.""" diff --git a/backend/app/modules/tasks/router.py b/backend/app/modules/tasks/router.py index f4c89bd..70a5be5 100644 --- a/backend/app/modules/tasks/router.py +++ b/backend/app/modules/tasks/router.py @@ -8,7 +8,7 @@ from fastapi.responses import JSONResponse from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps.auth import get_current_actor, get_registered_actor +from app.api.deps.auth import get_registered_actor from app.core.permissions import PermissionDenied from app.db.session import get_db_session from app.modules.actors.schemas import ActorProfileActivationRequest, ActorProfileResponse @@ -117,7 +117,7 @@ async def ensure_worker_profile( async def create_task( project_id: str, payload: TaskCreate, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> TaskResponse: """Create a draft task under a project.""" @@ -132,7 +132,7 @@ async def create_task( @router.get("/tasks/{task_id}", response_model=TaskResponse, response_model_exclude_none=True) async def get_task( task_id: str, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> TaskResponse: """Return one task by id.""" @@ -151,7 +151,7 @@ async def get_task( ) async def screen_task( task_id: str, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], payload: TaskTransitionRequest | None = None, ) -> TaskResponse: @@ -175,7 +175,7 @@ async def screen_task( ) async def release_task( task_id: str, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], payload: TaskTransitionRequest | None = None, ) -> TaskResponse: @@ -223,7 +223,7 @@ async def claim_task( ) async def start_task( task_id: str, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], payload: TaskTransitionRequest | None = None, ) -> TaskResponse: @@ -259,7 +259,7 @@ async def start_task( async def create_submission( task_id: str, payload: SubmissionCreate, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> SubmissionResponse | JSONResponse: """Create a submission packet version for a task.""" @@ -280,7 +280,7 @@ async def create_submission( ) async def list_task_submissions( task_id: str, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> list[SubmissionResponse]: """Return submission packet versions for one task.""" @@ -299,7 +299,7 @@ async def list_task_submissions( ) async def get_submission( submission_id: str, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> SubmissionResponse: """Return one submission packet version.""" @@ -318,7 +318,7 @@ async def get_submission( ) async def lock_submission( submission_id: str, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> SubmissionResponse: """Lock a submission packet before checker execution.""" @@ -333,7 +333,7 @@ async def lock_submission( @router.get("/tasks/{task_id}/audit-events", response_model=list[AuditEventResponse]) async def list_task_audit_events( task_id: str, - actor: Annotated[ActorContext, Depends(get_current_actor)], + actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> list[AuditEventResponse]: """Return audit events for one task.""" diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index c156221..6bf421f 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -7,6 +7,45 @@ from pydantic import BaseModel, Field +def sanitized_claim_snapshot(claim_snapshot: dict[str, Any]) -> dict[str, Any]: + """Return the audit-safe claim snapshot allowed in Workstream storage. + + Args: + claim_snapshot: Trusted token claims produced by the auth verifier. + + Returns: + Claim snapshot with relationship claims reduced to scope identity only. + """ + sanitized = dict(claim_snapshot) + relationship_profiles = sanitized.get("workstream_relationship_profiles") + if not isinstance(relationship_profiles, list): + return sanitized + + sanitized_relationships: list[dict[str, str]] = [] + for raw_profile in relationship_profiles: + if not isinstance(raw_profile, dict): + continue + if raw_profile.get("profile_type") != "project_owner": + continue + scope_type = raw_profile.get("scope_type") + scope_id = raw_profile.get("scope_id") + if not isinstance(scope_type, str) or not isinstance(scope_id, str): + continue + scope_type = scope_type.strip() + scope_id = scope_id.strip() + if not scope_type or not scope_id: + continue + sanitized_relationships.append( + { + "profile_type": "project_owner", + "scope_type": scope_type, + "scope_id": scope_id, + } + ) + sanitized["workstream_relationship_profiles"] = sanitized_relationships + return sanitized + + class ActorAuditContext(BaseModel): """Stable actor claims stored with auditable Workstream actions.""" @@ -43,7 +82,7 @@ def audit_context(self) -> ActorAuditContext: external_subject=self.external_subject, external_issuer=self.external_issuer, actor_roles=self.roles, - claim_snapshot=self.claim_snapshot, + claim_snapshot=sanitized_claim_snapshot(self.claim_snapshot), auth_source=self.auth_source, is_dev_auth=self.is_dev_auth, ) diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index c6241c9..63fdafa 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -415,6 +415,9 @@ async def test_scoped_project_owner_profile_comes_from_trusted_relationship_clai await service.register_actor(actor) async with db_session.get_session_factory()() as session: + identity = await session.scalar( + select(ActorIdentity).where(ActorIdentity.actor_id == actor.actor_id) + ) profiles = ( await session.execute( select(ActorProfile).where( @@ -423,12 +426,40 @@ async def test_scoped_project_owner_profile_comes_from_trusted_relationship_clai ) ) ).scalars().all() + audit_events = ( + await session.execute( + select(AuditEvent).where( + AuditEvent.entity_type == "actor_profile", + AuditEvent.actor_id == actor.actor_id, + ) + ) + ).scalars().all() + assert identity is not None + identity_snapshot = identity.last_claim_snapshot + assert identity_snapshot["workstream_relationship_profiles"] == [ + { + "profile_type": "project_owner", + "scope_type": "project", + "scope_id": "project-123", + } + ] + assert "must-not-persist" not in str(identity_snapshot) + assert "api_key" not in str(identity_snapshot) + assert "secret" not in str(identity_snapshot).lower() + assert "token" not in str(identity_snapshot).lower() assert len(profiles) == 1 assert profiles[0].status == "observed" assert profiles[0].scope_type == "project" assert profiles[0].scope_id == "project-123" assert profiles[0].profile_metadata == {"source": "trusted_relationship_claim"} + assert audit_events + for audit_event in audit_events: + claim_snapshot = audit_event.claim_snapshot + assert "must-not-persist" not in str(claim_snapshot) + assert "api_key" not in str(claim_snapshot) + assert "secret" not in str(claim_snapshot).lower() + assert "token" not in str(claim_snapshot).lower() async def test_active_profile_without_matching_token_role_cannot_use_worker_profile_api( diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index e7eb5d0..d135d91 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -28,6 +28,7 @@ from app.db import session as db_session from app.db.base import Base from app.main import create_app +from app.modules.actors.models import ActorIdentity, ActorProfile from app.interfaces.project_agents import ( GuideSourceMaterial, GuideSufficiencyAgentResult, @@ -452,6 +453,37 @@ async def create_project(client: AsyncClient) -> dict: return response.json() +async def test_project_route_registers_project_manager_actor_without_auth_me( + project_client: AsyncClient, +) -> None: + response = await project_client.post( + "/api/v1/projects", + headers=auth_headers(), + json={ + "name": "Registry Proof", + "slug": "registry-proof", + "description": "Proves product routes observe actors directly", + }, + ) + assert response.status_code == 201, response.text + + async with db_session.get_session_factory()() as session: + identity = await session.scalar( + select(ActorIdentity).where( + ActorIdentity.external_subject == "project-manager-subject" + ) + ) + assert identity is not None + profiles = ( + await session.execute( + select(ActorProfile).where(ActorProfile.actor_id == identity.actor_id) + ) + ).scalars().all() + + assert identity.last_seen_roles == ["project_manager"] + assert any(profile.profile_type == "project_manager" for profile in profiles) + + async def create_guide(client: AsyncClient, project_id: str, payload: dict) -> dict: response = await client.post( f"/api/v1/projects/{project_id}/guides", diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 799f574..ade1ed7 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -127,8 +127,10 @@ and does not grant operator access. The trusted relationship claim key is `claim_snapshot["workstream_relationship_profiles"]`. Each item must use `profile_type = "project_owner"`, a non-empty `scope_type`, a non-empty -`scope_id`, and optional object `profile_metadata`. These values are persisted -as observed relationship metadata only; they are not route authorization. +`scope_id`. Workstream stores only those scope identity fields in actor +identity/audit claim snapshots and stores server-owned profile metadata for the +observed relationship. Nested relationship `profile_metadata` from token claims +is discarded before persistence and is not route authorization. Trusted v0.1 bootstrap request roles: diff --git a/docs/operations_roles_permissions.md b/docs/operations_roles_permissions.md index d989487..0f929ec 100644 --- a/docs/operations_roles_permissions.md +++ b/docs/operations_roles_permissions.md @@ -38,8 +38,9 @@ approve Workstream machine-readable policies unless the verified token also carries an authorized Workstream role such as admin or project manager. The token claim key for trusted relationship metadata is `workstream_relationship_profiles`; v0.1 accepts only -`profile_type="project_owner"` with non-empty `scope_type` and `scope_id`, plus -optional object `profile_metadata`. +`profile_type="project_owner"` with non-empty `scope_type` and `scope_id`. +Workstream discards any nested relationship `profile_metadata` from token +claims before writing actor identity, profile, or audit records. Actor profile status is a workflow condition, not route permission. An `observed` profile only records that Workstream saw the actor through a verified diff --git a/docs/spec_chunk_2_auth_actor_boundary.md b/docs/spec_chunk_2_auth_actor_boundary.md index 6e3dd9a..ce00767 100644 --- a/docs/spec_chunk_2_auth_actor_boundary.md +++ b/docs/spec_chunk_2_auth_actor_boundary.md @@ -51,7 +51,9 @@ Forward note: `WS-POL-001-11` later adds `ActorIdentity` and `ActorProfile` tables. That does not change this chunk's authentication boundary: `get_current_actor` remains pure token verification. Routes that need local registry side effects use a separate registration dependency after actor -resolution. +resolution. After `WS-POL-001-11`, authenticated Workstream product routes use +that registration dependency so actor identity/profile observation does not +depend on a prior `/auth/me` call. The in-request actor context must expose: @@ -70,12 +72,11 @@ object with: - `profile_type`: currently only `project_owner` - `scope_type`: non-empty string such as `project` - `scope_id`: non-empty string for that scope -- `profile_metadata`: optional object for non-authoritative contact/source - metadata These claims are token-derived relationship metadata. They may create observed scoped `ActorProfile` rows for audit/display, but they do not authorize routes -or approve Workstream machine-readable policy. +or approve Workstream machine-readable policy. Nested relationship +`profile_metadata` from token claims is discarded before persistence. ## API Impact From 160a6ae55e5362278382986a596e57c0e8f374df Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 21:22:48 +0100 Subject: [PATCH 10/25] Allowlist stored actor claim snapshots --- ...S-POL-001-11-actor-identity-profile-registry.md | 4 ++++ backend/app/schemas/auth.py | 14 ++++++++++++-- backend/tests/test_actors.py | 12 ++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md index 61d1441..ce47a6c 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md @@ -70,11 +70,14 @@ backend/app/api/deps/auth.py backend/app/api/routes/auth.py backend/app/core/config.py backend/app/db/models.py +backend/app/schemas/auth.py backend/app/modules/actors/__init__.py backend/app/modules/actors/models.py backend/app/modules/actors/repository.py backend/app/modules/actors/schemas.py backend/app/modules/actors/service.py +backend/app/modules/checkers/router.py +backend/app/modules/projects/router.py backend/app/modules/tasks/models.py backend/app/modules/tasks/repository.py backend/app/modules/tasks/router.py @@ -83,6 +86,7 @@ backend/app/modules/tasks/service.py backend/tests/test_actors.py backend/tests/test_alembic.py backend/tests/test_auth.py +backend/tests/test_projects.py backend/tests/test_tasks.py backend/scripts/api_contract_e2e.py backend/scripts/week2_api_e2e.py diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 6bf421f..eb1301e 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -16,8 +16,18 @@ def sanitized_claim_snapshot(claim_snapshot: dict[str, Any]) -> dict[str, Any]: Returns: Claim snapshot with relationship claims reduced to scope identity only. """ - sanitized = dict(claim_snapshot) - relationship_profiles = sanitized.get("workstream_relationship_profiles") + sanitized: dict[str, Any] = {} + raw_roles = claim_snapshot.get("roles") + if isinstance(raw_roles, list | tuple): + sanitized["roles"] = [role for role in raw_roles if isinstance(role, str)] + elif isinstance(raw_roles, str): + sanitized["roles"] = [raw_roles] + + claim_source = claim_snapshot.get("claim_source") + if isinstance(claim_source, str): + sanitized["claim_source"] = claim_source + + relationship_profiles = claim_snapshot.get("workstream_relationship_profiles") if not isinstance(relationship_profiles, list): return sanitized diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index 63fdafa..57274f2 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -396,6 +396,11 @@ async def test_scoped_project_owner_profile_comes_from_trusted_relationship_clai subject="source-contact", roles=("project_manager",), claim_snapshot={ + "roles": ["project_manager"], + "access_token": "must-not-persist", + "email": "must-not-persist@example.test", + "nested": {"api_key": "must-not-persist"}, + "secret": "must-not-persist", "workstream_relationship_profiles": [ { "profile_type": "project_owner", @@ -437,6 +442,7 @@ async def test_scoped_project_owner_profile_comes_from_trusted_relationship_clai assert identity is not None identity_snapshot = identity.last_claim_snapshot + assert identity_snapshot["roles"] == ["project_manager"] assert identity_snapshot["workstream_relationship_profiles"] == [ { "profile_type": "project_owner", @@ -446,6 +452,9 @@ async def test_scoped_project_owner_profile_comes_from_trusted_relationship_clai ] assert "must-not-persist" not in str(identity_snapshot) assert "api_key" not in str(identity_snapshot) + assert "access_token" not in str(identity_snapshot) + assert "nested" not in str(identity_snapshot) + assert "email" not in str(identity_snapshot) assert "secret" not in str(identity_snapshot).lower() assert "token" not in str(identity_snapshot).lower() assert len(profiles) == 1 @@ -458,6 +467,9 @@ async def test_scoped_project_owner_profile_comes_from_trusted_relationship_clai claim_snapshot = audit_event.claim_snapshot assert "must-not-persist" not in str(claim_snapshot) assert "api_key" not in str(claim_snapshot) + assert "access_token" not in str(claim_snapshot) + assert "nested" not in str(claim_snapshot) + assert "email" not in str(claim_snapshot) assert "secret" not in str(claim_snapshot).lower() assert "token" not in str(claim_snapshot).lower() From a36fb2055fb290d8355cb325ffd630c62a7c0d32 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 21:41:21 +0100 Subject: [PATCH 11/25] Make actor profile audits idempotent --- backend/app/modules/actors/repository.py | 9 ++++- backend/app/modules/actors/service.py | 38 ++++++++++--------- ...026-06-12_week2_closeout_real_api_drill.md | 6 ++- docs/operations_roles_permissions.md | 2 +- docs/roadmap_30_day_master_plan.md | 2 +- docs/roadmap_day_by_day_execution_plan.md | 4 +- docs/spec_week2_checker_framework.md | 2 +- 7 files changed, 36 insertions(+), 27 deletions(-) diff --git a/backend/app/modules/actors/repository.py b/backend/app/modules/actors/repository.py index 17b6041..03cff59 100644 --- a/backend/app/modules/actors/repository.py +++ b/backend/app/modules/actors/repository.py @@ -134,13 +134,16 @@ async def list_profiles(self, actor_id: str) -> Sequence[ActorProfile]: ) return result.scalars().all() - async def insert_profile_if_absent(self, profile: ActorProfile) -> None: + async def insert_profile_if_absent(self, profile: ActorProfile) -> bool: """Insert a profile without overwriting existing scoped profile state. Args: profile: Actor profile to insert when absent. + + Returns: + True when this call inserted the profile; false on conflict. """ - await self._session.execute( + result = await self._session.execute( insert(ActorProfile) .values( id=profile.id, @@ -160,5 +163,7 @@ async def insert_profile_if_absent(self, profile: ActorProfile) -> None: ActorProfile.scope_id, ] ) + .returning(ActorProfile.id) ) await self._session.flush() + return result.scalar_one_or_none() is not None diff --git a/backend/app/modules/actors/service.py b/backend/app/modules/actors/service.py index fa693b0..3765b4d 100644 --- a/backend/app/modules/actors/service.py +++ b/backend/app/modules/actors/service.py @@ -120,7 +120,7 @@ async def ensure_observed_profile( await self._repo.upsert_identity(self._identity_from_actor(actor)) existing = await self._repo.get_profile(actor.actor_id, profile_type, scope_type, scope_id) if existing is None: - await self._repo.insert_profile_if_absent( + inserted = await self._repo.insert_profile_if_absent( ActorProfile( id=str(uuid4()), actor_id=actor.actor_id, @@ -135,14 +135,15 @@ async def ensure_observed_profile( profile = await self._repo.get_profile(actor.actor_id, profile_type, scope_type, scope_id) if profile is None: raise RuntimeError("actor profile insert did not return a persisted row") - await self._write_profile_audit( - actor, - profile, - event_type="actor_profile_observed", - from_status=None, - to_status=profile.status, - event_payload={"profile_metadata": profile.profile_metadata}, - ) + if inserted: + await self._write_profile_audit( + actor, + profile, + event_type="actor_profile_observed", + from_status=None, + to_status=profile.status, + event_payload={"profile_metadata": profile.profile_metadata}, + ) return profile if existing.status != "observed": @@ -201,7 +202,7 @@ async def activate_worker_profile( GLOBAL_PROFILE_SCOPE_ID, ) if profile is None: - await self._repo.insert_profile_if_absent( + inserted = await self._repo.insert_profile_if_absent( ActorProfile( id=str(uuid4()), actor_id=actor.actor_id, @@ -221,14 +222,15 @@ async def activate_worker_profile( ) if profile is None: raise RuntimeError("worker actor profile insert did not return a persisted row") - await self._write_profile_audit( - actor, - profile, - event_type="actor_profile_activated", - from_status=None, - to_status="active", - event_payload={"skill_tags": profile.skill_tags}, - ) + if inserted: + await self._write_profile_audit( + actor, + profile, + event_type="actor_profile_activated", + from_status=None, + to_status="active", + event_payload={"skill_tags": profile.skill_tags}, + ) else: if profile.status == "disabled": raise ActorProfileDisabled("worker profile is disabled") diff --git a/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md b/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md index 1fd306a..a471cea 100644 --- a/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md +++ b/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md @@ -51,7 +51,8 @@ Security re-review confirmed: - non-Postgres URL is blocked - local async Postgres URLs for `workstream` and `workstream_test` are allowed - Flow-token auth remains in use -- demo worker-profile route remains local/test gated +- superseded: the local demo worker-profile route was later removed; current + drills use `POST /api/v1/workers/me/profile` - worker redaction and reviewer denial are asserted - no production route or checker-read permission is widened @@ -60,7 +61,8 @@ Security re-review confirmed: Findings: - no blocking findings -- the demo worker-profile helper is acceptable for v0.1 drills but should not be described as production worker onboarding +- superseded: the demo worker-profile helper was removed; current drills use + the canonical worker profile API - uppercase lifecycle labels must not drift into persisted token contracts Resolution: roadmap wording keeps Week 3 readiness explicit and does not overclaim reviewer checker visibility. diff --git a/docs/operations_roles_permissions.md b/docs/operations_roles_permissions.md index 0f929ec..e162a8c 100644 --- a/docs/operations_roles_permissions.md +++ b/docs/operations_roles_permissions.md @@ -51,7 +51,7 @@ the route still requires the matching role in the current verified token. | Action | Admin | Project Manager | Worker | Reviewer | Finance | Auditor | | --- | --- | --- | --- | --- | --- | --- | -| Create project | yes | no | no | no | no | no | +| Create project | yes | yes | no | no | no | no | | Edit project guide | yes | yes | no | no | no | no | | Create task | yes | yes | no | no | no | no | | Claim task | no | no | yes | no | no | no | diff --git a/docs/roadmap_30_day_master_plan.md b/docs/roadmap_30_day_master_plan.md index 4f7228f..c640932 100644 --- a/docs/roadmap_30_day_master_plan.md +++ b/docs/roadmap_30_day_master_plan.md @@ -144,7 +144,7 @@ This thin slice is the first proof that Workstream can measure and certify usefu Objective: prevent worker-fixable submission failures and locked task setup defects from reaching human review. -Week 2 is backend/checker-framework work. Checker results must be available through backend APIs, dry-run scripts, and demo/debug output. Product frontend pages, reviewer queue UI, and review decision screens stay in Week 3 or later. +Week 2 is backend/checker-framework work. Checker results must be available through backend APIs, backend contract drills, and operational debug output. Product frontend pages, reviewer queue UI, and review decision screens stay in Week 3 or later. Deliverables: diff --git a/docs/roadmap_day_by_day_execution_plan.md b/docs/roadmap_day_by_day_execution_plan.md index 1f73779..51f5096 100644 --- a/docs/roadmap_day_by_day_execution_plan.md +++ b/docs/roadmap_day_by_day_execution_plan.md @@ -133,7 +133,7 @@ Exit criteria: ## Week 2: Checker System -Week 2 is backend-first checker infrastructure. Checker output is exposed through APIs, dry-run scripts, and demo/debug output. It does not build the product frontend, reviewer queue UI, review decision form, contribution records, payment records, or reputation updates. +Week 2 is backend-first checker infrastructure. Checker output is exposed through APIs, backend contract drills, and operational debug output. It does not build the product frontend, reviewer queue UI, review decision form, contribution records, payment records, or reputation updates. The core invariant is: @@ -155,7 +155,7 @@ Exit criteria: - run a checker against a submission - store `passed`, `warning`, and `failed` results -- expose checker run and result data through backend API responses and dry-run/demo output +- expose checker run and result data through backend API responses and API drill/debug output - no product frontend task page is added in Week 2 - checker records can distinguish pre-submit feedback from post-submit internal auto checks diff --git a/docs/spec_week2_checker_framework.md b/docs/spec_week2_checker_framework.md index 6f82262..520016c 100644 --- a/docs/spec_week2_checker_framework.md +++ b/docs/spec_week2_checker_framework.md @@ -18,7 +18,7 @@ The checker framework protects reviewer time by proving that the latest locked s - blocking versus warning calculation - user-facing `needs_revision` routing from checker failures - backend API access to checker runs and results -- dry-run/demo output for checker visibility +- backend API drill/debug output for checker visibility - pre-review gate enforcement for `REVIEW_PENDING` ## Non-Scope From d2edd0506c2d2e6b2cb6debf624f4d8718d81ddd Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 22:00:21 +0100 Subject: [PATCH 12/25] Constrain Flow role snapshots --- backend/app/adapters/auth/flow.py | 2 +- backend/tests/test_auth.py | 14 +++++++++++++- docs/roadmap_status.md | 4 ++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/backend/app/adapters/auth/flow.py b/backend/app/adapters/auth/flow.py index 6aaef43..5995871 100644 --- a/backend/app/adapters/auth/flow.py +++ b/backend/app/adapters/auth/flow.py @@ -87,7 +87,7 @@ def _normalize_roles(value: Any) -> tuple[str, ...]: raw_roles = value else: raw_roles = () - return tuple(str(role).strip() for role in raw_roles if str(role).strip()) + return tuple(role.strip() for role in raw_roles if isinstance(role, str) and role.strip()) class FlowAuthVerifier: diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index e25ae51..fef46ae 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -11,7 +11,7 @@ from sqlalchemy.exc import SQLAlchemyError from app.adapters.auth.dev import DevelopmentAuthVerifier -from app.adapters.auth.flow import FlowAuthVerifier +from app.adapters.auth.flow import FlowAuthVerifier, _normalize_roles from app.core.config import Settings, get_settings from app.core.permissions import PermissionDenied, require_any_role from app.db import session as db_session @@ -252,6 +252,18 @@ async def test_flow_auth_verifier_boundary_rejects_unconfigured_verification() - await verifier.verify("flow-token") +async def test_flow_role_normalization_ignores_non_string_values() -> None: + assert _normalize_roles( + [ + "worker", + {"api_key": "must-not-persist"}, + 42, + " reviewer ", + "", + ] + ) == ("worker", "reviewer") + + async def test_permission_policy_allows_required_role() -> None: actor = await DevelopmentAuthVerifier( Settings( diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index a265bec..8ad1601 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -106,12 +106,12 @@ The Week 2 closeout gate is deterministic and must fail on contract drift. Required invariants: - Real API drills run only against local `postgresql+asyncpg://` test databases named `workstream_test` or `test_workstream` unless an explicit write-risk override is supplied. -- Pre-submit checker responses are authoritative for submission intake and must not create submissions, checker runs, or lifecycle transitions when blocking failures exist. +- Pre-submit checker responses are non-authoritative preflight feedback; submission creation is the authoritative intake gate and must not create submissions, checker runs, or lifecycle transitions when blocking failures exist. - Missing or unexpected pre-submit checker names fail the drill. - Missing or unexpected durable checker names fail the drill. - Submission locking returns `locked_at`, locks evidence rows, and is idempotent. - Automatic checker-run creation, checker terminal status, and task-status transitions are polled because execution is async-first. -- Checker-run list visibility is checked for project manager, assigned worker, unassigned worker, and reviewer roles. +- Checker-run list visibility is checked for project manager, assigned worker, unassigned worker, and reviewer denial while broad reviewer checker-run access remains deferred. - Trusted checker retry proves attempt ordering, supersession, and current-run flags. - Postgres invariants are checked after the real API flows for locked guide/policy context, evidence locks, checker results, checker counters, current-run uniqueness, and gate audit events. From 8a5c85c0d133376f6737fcc53178c76bbea5a6f8 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 22:12:11 +0100 Subject: [PATCH 13/25] Tighten actor snapshot contract --- .../chunks/WS-POL-001-11-actor-identity-profile-registry.md | 4 +++- backend/app/schemas/auth.py | 4 ---- backend/tests/test_actors.py | 3 +++ 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md index ce47a6c..2ae7e2a 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md @@ -68,6 +68,7 @@ backend/alembic/versions/*_actor_identity_profile_registry.py backend/app/api/router.py backend/app/api/deps/auth.py backend/app/api/routes/auth.py +backend/app/adapters/auth/flow.py backend/app/core/config.py backend/app/db/models.py backend/app/schemas/auth.py @@ -124,7 +125,8 @@ roles or permissions sourced from ActorIdentity instead of the verified token roles or permissions sourced from ActorProfile instead of the verified token canonical product roles sourced from Identity Issuer token claims instead of Workstream-owned role-assignment records once that layer exists automatic task claiming, reviewing, payment, or project access from profiles alone -worker/reviewer routing changes beyond moving profile storage to ActorProfile and preserving current worker-profile API behavior +worker/reviewer routing changes beyond moving profile storage to ActorProfile and preserving the canonical worker-profile API behavior +compatibility backfill or restoration of obsolete worker/reviewer profile rows task lifecycle, submission, checker, review, revision, payment, or reputation behavior changes agent runtime, project setup pipeline, Celery, storage, frontend, or demo feature changes blockchain, ERC-8004, ERC-8183, settlement, marketplace, or external source adapters diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index eb1301e..4a992cd 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -23,10 +23,6 @@ def sanitized_claim_snapshot(claim_snapshot: dict[str, Any]) -> dict[str, Any]: elif isinstance(raw_roles, str): sanitized["roles"] = [raw_roles] - claim_source = claim_snapshot.get("claim_source") - if isinstance(claim_source, str): - sanitized["claim_source"] = claim_source - relationship_profiles = claim_snapshot.get("workstream_relationship_profiles") if not isinstance(relationship_profiles, list): return sanitized diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index 57274f2..942cb7a 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -398,6 +398,7 @@ async def test_scoped_project_owner_profile_comes_from_trusted_relationship_clai claim_snapshot={ "roles": ["project_manager"], "access_token": "must-not-persist", + "claim_source": "must-not-persist", "email": "must-not-persist@example.test", "nested": {"api_key": "must-not-persist"}, "secret": "must-not-persist", @@ -453,6 +454,7 @@ async def test_scoped_project_owner_profile_comes_from_trusted_relationship_clai assert "must-not-persist" not in str(identity_snapshot) assert "api_key" not in str(identity_snapshot) assert "access_token" not in str(identity_snapshot) + assert "claim_source" not in str(identity_snapshot) assert "nested" not in str(identity_snapshot) assert "email" not in str(identity_snapshot) assert "secret" not in str(identity_snapshot).lower() @@ -468,6 +470,7 @@ async def test_scoped_project_owner_profile_comes_from_trusted_relationship_clai assert "must-not-persist" not in str(claim_snapshot) assert "api_key" not in str(claim_snapshot) assert "access_token" not in str(claim_snapshot) + assert "claim_source" not in str(claim_snapshot) assert "nested" not in str(claim_snapshot) assert "email" not in str(claim_snapshot) assert "secret" not in str(claim_snapshot).lower() From 117feb1cf1b133f7cf40b400994bb9c8aa43afa1 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 22:28:12 +0100 Subject: [PATCH 14/25] Normalize stored actor snapshot roles --- backend/app/schemas/auth.py | 8 ++++++-- backend/tests/test_actors.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 4a992cd..0a9f952 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -19,9 +19,13 @@ def sanitized_claim_snapshot(claim_snapshot: dict[str, Any]) -> dict[str, Any]: sanitized: dict[str, Any] = {} raw_roles = claim_snapshot.get("roles") if isinstance(raw_roles, list | tuple): - sanitized["roles"] = [role for role in raw_roles if isinstance(role, str)] + roles = [role.strip() for role in raw_roles if isinstance(role, str) and role.strip()] elif isinstance(raw_roles, str): - sanitized["roles"] = [raw_roles] + roles = [role.strip() for role in raw_roles.split(",") if role.strip()] + else: + roles = [] + if roles: + sanitized["roles"] = roles relationship_profiles = claim_snapshot.get("workstream_relationship_profiles") if not isinstance(relationship_profiles, list): diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index 942cb7a..6be60c5 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -396,7 +396,7 @@ async def test_scoped_project_owner_profile_comes_from_trusted_relationship_clai subject="source-contact", roles=("project_manager",), claim_snapshot={ - "roles": ["project_manager"], + "roles": [" project_manager ", ""], "access_token": "must-not-persist", "claim_source": "must-not-persist", "email": "must-not-persist@example.test", From 1c5d816dbc2ca07f60bd219fa40daeb26bca58f3 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Mon, 6 Jul 2026 22:49:25 +0100 Subject: [PATCH 15/25] Align actor registry final review fixes --- .agent-loop/REVIEW_LOG.md | 2 +- backend/app/modules/actors/service.py | 6 +++++ backend/app/modules/projects/service.py | 1 - backend/tests/test_actors.py | 35 +++++++++++++++++++++++++ backend/tests/test_tasks.py | 2 +- docs/operations_roles_permissions.md | 2 +- 6 files changed, 44 insertions(+), 4 deletions(-) diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 5579ddf..d850395 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -317,7 +317,7 @@ Required reviewer tracks: Result: PASS after fixes from internal review. Scope: local `ActorIdentity` and shared `ActorProfile` registry for verified -Flow actors, migration/backfill/removal of old worker/reviewer profile stores, +Flow actors, destructive removal of obsolete worker/reviewer profile stores, explicit actor-registration dependency, worker profile activation through the canonical worker endpoint, claim eligibility requiring verified worker token role plus active worker profile, stale demo route cleanup, and Flow diff --git a/backend/app/modules/actors/service.py b/backend/app/modules/actors/service.py index 3765b4d..2e1c378 100644 --- a/backend/app/modules/actors/service.py +++ b/backend/app/modules/actors/service.py @@ -300,6 +300,7 @@ async def _can_skip_registry_refresh( "profile_type": profile_type, "scope_type": GLOBAL_PROFILE_SCOPE_TYPE, "scope_id": GLOBAL_PROFILE_SCOPE_ID, + "profile_metadata": {"source": "verified_token_role"}, } for profile_type in self._observed_profile_types(actor.roles) ] @@ -313,6 +314,11 @@ async def _can_skip_registry_refresh( ) if profile is None: return False + if ( + profile.status == "observed" + and profile.profile_metadata != required_profile.get("profile_metadata") + ): + return False return True def _identity_claims_match( diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index 4748bad..c9a2860 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -226,7 +226,6 @@ def agent_submission_artifact_policy_version(source_snapshot_hash: str) -> str: "rubrics", "source-material", "task-docs", - "week1", } GUIDE_SOURCE_MATERIAL_FIELDS = { "content_markdown", diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index 6be60c5..6b1f41f 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -187,6 +187,41 @@ async def test_repeated_auth_me_does_not_rewrite_unchanged_observed_profile( assert profile_rows[0].updated_at == stale_time +async def test_auth_me_refreshes_stale_observed_profile_metadata( + actor_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + set_dev_actor(monkeypatch, roles="reviewer", subject="metadata-refresh-reviewer") + created = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) + assert created.status_code == 200, created.text + stale_time = datetime.now(UTC) - timedelta(days=1) + async with db_session.get_session_factory()() as session: + profile = await session.scalar( + select(ActorProfile).where( + ActorProfile.actor_id == actor_id("metadata-refresh-reviewer"), + ActorProfile.profile_type == "reviewer", + ) + ) + assert profile is not None + profile.profile_metadata = {"source": "stale"} + profile.updated_at = stale_time + await session.commit() + + refreshed = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) + assert refreshed.status_code == 200, refreshed.text + async with db_session.get_session_factory()() as session: + profile = await session.scalar( + select(ActorProfile).where( + ActorProfile.actor_id == actor_id("metadata-refresh-reviewer"), + ActorProfile.profile_type == "reviewer", + ) + ) + + assert profile is not None + assert profile.profile_metadata == {"source": "verified_token_role"} + assert profile.updated_at > stale_time + + async def test_auth_me_refreshes_identity_after_configured_interval( actor_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 9a86d29..c4dcd5b 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -2860,7 +2860,7 @@ async def test_cross_worker_cannot_list_submissions_or_audit_after_submit( @pytest.mark.parametrize("role", ["reviewer", "finance", "auditor"]) -async def test_future_roles_cannot_view_week1_task_or_submissions( +async def test_future_roles_cannot_view_unassigned_task_or_submissions( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, role: str, diff --git a/docs/operations_roles_permissions.md b/docs/operations_roles_permissions.md index e162a8c..406d140 100644 --- a/docs/operations_roles_permissions.md +++ b/docs/operations_roles_permissions.md @@ -86,7 +86,7 @@ local actor and role records for that issuer plus subject. The first durable shape should be: ```text -WorkstreamActor +ActorIdentity - id - issuer - subject From ce57958666bb28c9112b6513ec16f504c5fd1571 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 7 Jul 2026 04:30:22 +0100 Subject: [PATCH 16/25] Align actor registry chunk scope --- .../chunks/WS-POL-001-11-actor-identity-profile-registry.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md index 2ae7e2a..129ef2f 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md @@ -79,6 +79,7 @@ backend/app/modules/actors/schemas.py backend/app/modules/actors/service.py backend/app/modules/checkers/router.py backend/app/modules/projects/router.py +backend/app/modules/projects/service.py backend/app/modules/tasks/models.py backend/app/modules/tasks/repository.py backend/app/modules/tasks/router.py From 96f2cdca1e659d35052837a433d6a95382095ddb Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 7 Jul 2026 04:49:31 +0100 Subject: [PATCH 17/25] Update actor registry review evidence --- .../WS-POL-001-11-internal-review-evidence.md | 46 +++++++++---------- .../reviews/WS-POL-001-11-pr-trust-bundle.md | 44 +++++++++--------- ...026-06-12_week2_closeout_real_api_drill.md | 8 ++-- ...-13_week1_week2_deterministic_hardening.md | 4 +- 4 files changed, 48 insertions(+), 54 deletions(-) diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md index b42dcb6..9cb944f 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md @@ -10,11 +10,11 @@ valid findings addressed: yes ## Reviewed Revision -Reviewed code SHA: 912a1bef3ce7065e9563a03a440b24efe6af3f89 +Reviewed code SHA: ce57958666bb28c9112b6513ec16f504c5fd1571 -Reviewed at: 2026-07-06T16:17:45Z +Reviewed at: 2026-07-07T03:45:00Z -Reviewer run IDs: senior-engineering-review-019f37c9-f47a-7fa2-ac9b-639db0d4943a, qa-test-review-019f3804-d7c9-7f40-9a55-abb3ab0ea152, security-auth-review-019f37ca-2035-7e93-b63d-e53c632ee23a, product-ops-review-019f37ca-2da9-7670-a0a1-9bb351b0ecf1, architecture-review-019f382c-b040-7ac3-a7f6-f26b17fbe533, docs-review-019f3805-095b-7490-b410-3b28688bc979, reuse-dedup-review-019f3805-369e-7050-ba48-b1aca7c8208c, test-delta-review-019f3805-5f52-73b1-9aba-6254ce075cd9, ci-integrity-review-019f3836-8680-7f90-be04-b79fa169f57b +Reviewer run IDs: senior-engineering-review-019f3aa7-a13d-7052-85dc-635cbfa7dadb, qa-test-review-019f3aa7-a9a4-7e80-9fb5-8b9961b0c913, security-auth-review-019f3aa7-b1b3-73f2-959e-9eaae25fabd3, product-ops-review-019f3aa7-bce6-77b1-9053-09926c54b4c9 After the reviewed SHA, only evidence and status files changed. @@ -24,7 +24,7 @@ Scope: - Implements local `ActorIdentity` and shared `ActorProfile` persistence for verified Flow actors. - Keeps `get_current_actor` as the pure Flow-token boundary and adds `get_registered_actor` for explicit registry side effects. -- Migrates legacy `worker_profiles` and `reviewer_profiles` into `actor_identities` and `actor_profiles`, then removes the old stores. +- Creates new `actor_identities` and `actor_profiles` tables, then drops obsolete `worker_profiles` and `reviewer_profiles` without compatibility backfill. - Makes worker profile activation write `ActorProfile(profile_type="worker", status="active")` through the actor module. - Makes task claim require both a verified `worker` token role and an active worker profile. - Preserves active/disabled profile metadata during token observation refreshes. @@ -36,10 +36,10 @@ Scope: | Reviewer | Result | Blocking findings | Notes | |---|---:|---|---| -| senior engineering | PASS AFTER FIXES | None | Fixed profile freshness updates, redundant identity upserts, and documented v0.1 audit ledger coupling. | -| QA/test | PASS | None | Confirmed migration/backfill/removal, registered auth boundary, profile eligibility, overposting protection, and demo/script rewiring. | -| security/auth | PASS AFTER FIXES | None | Confirmed token roles remain route authority; scoped docs stayed inside the chunk after reverting out-of-scope roadmap edits. | -| product/ops | PASS | None | Confirmed actor/profile workflow semantics after stale loop evidence was treated as pending final evidence work. | +| senior engineering | PASS AFTER FIXES | None | Required stale evidence wording to be fixed so the PR no longer claims compatibility backfill or deleted demo UI proof. | +| QA/test | PASS WITH LOW RISKS | None | Required stale trust-bundle proof wording to be fixed; no code/test blockers found. | +| security/auth | PASS | None | Confirmed no valid security/auth findings on the final code SHA. | +| product/ops | PASS AFTER FIXES | None | Required stale backfill wording to be fixed so operator expectations match destructive removal/no compatibility backfill. | | architecture | PASS WITH LOW RISKS | None | Confirmed Flow auth boundary, actor/profile non-auth semantics, worker claim gate, and documented v0.1 audit ledger coupling. | | CI integrity | PASS | None | Confirmed the post-PR lint fix only adds the missing `uuid4` import and does not weaken CI, lint, tests, typecheck, coverage, workflows, or package scripts. | | docs | PASS WITH LOW RISKS | None | Confirmed docs align after adding `workstream_relationship_profiles` schema, audit schema, demo cleanup, and issuer-plus-subject wording. | @@ -53,40 +53,38 @@ Scope: - Reused `TaskRepository.add_audit_event` for actor profile audit writes instead of adding a parallel audit persistence path. - Removed the redundant task-service worker-profile facade; the route calls `ActorService.activate_worker_profile` directly. - Added persisted-value overposting assertions for `POST /api/v1/workers/me/profile` and task claim so spoofed identity fields cannot write malicious registry rows. -- Added migration downgrade assertions proving legacy worker/reviewer rows are restored with actor id, subject, issuer, display/email, status, and skill tags. +- Added migration assertions proving new actor registry tables exist and obsolete worker/reviewer profile tables are removed rather than kept as compatibility stores. - Added metadata negative assertions so old `worker_profiles`/`reviewer_profiles` metadata exports cannot silently return. -- Removed stale `/api/v1/demo/worker-profile` usage from backend scripts, Terminal Benchmark example, README, and the Week 1 demo UI. -- Removed stale `WORKSTREAM_ENABLE_DEMO_ROUTES=true` guidance where the Week 1 demo no longer needs demo routes. +- Removed stale `/api/v1/demo/worker-profile` usage from backend scripts, Terminal Benchmark example, and README. +- Removed the obsolete Week 1 demo UI package and workflow instead of preserving a stale compatibility surface. - Documented exact `workstream_relationship_profiles` trusted claim schema and its non-authorizing behavior. - Updated audit-event docs to match actual `actor_roles`, `from_status`, `to_status`, `is_dev_auth`, and `event_payload` fields. - Aligned Flow Identity wording so docs consistently name Flow issuer plus subject as the canonical identity anchor. -- Fixed the GitHub Backend/Lint failure by importing `uuid4` in the Week 1 dry-run script. +- Fixed stale evidence after final internal review so the trust bundle no longer claims compatibility backfill or deleted demo UI proof. ## Commands Run ```bash -cd backend && .venv/bin/python -m ruff check app/api/deps/auth.py app/api/routes/auth.py app/modules/actors app/modules/tasks/models.py app/modules/tasks/repository.py app/modules/tasks/router.py app/modules/tasks/schemas.py app/modules/tasks/service.py tests/test_actors.py tests/test_alembic.py tests/test_auth.py tests/test_tasks.py -cd backend && .venv/bin/docstr-coverage app/api app/modules/actors app/modules/tasks --config .docstr.yaml +cd backend && .venv/bin/python -m ruff check app/modules/actors/service.py app/modules/projects/service.py tests/test_actors.py tests/test_tasks.py python3 scripts/check_stale_workstream_wording.py python3 scripts/check_markdown_links.py -git diff --check +git diff --check origin/main...HEAD +python3 scripts/test_agent_gates.py cd backend && .venv/bin/python -m pytest tests/test_alembic.py tests/test_actors.py tests/test_auth.py -q -cd backend && .venv/bin/python -m pytest tests/test_tasks.py -q -cd demos/week1_api_demo_ui && npm run build -rg -n 'worker_profile_setup=demo_bootstrap|demo worker profile|Activates demo worker profile|WORKSTREAM_ENABLE_DEMO_ROUTES|/api/v1/demo/worker-profile|WorkerProfile|ReviewerProfile' backend/scripts examples/terminal_benchmark backend/app/api/routes/demo.py backend/app/modules README.md demos/week1_api_demo_ui/src/App.tsx docs/spec_chunk_2_auth_actor_boundary.md docs/architecture_data_model.md docs/operations_roles_permissions.md +cd backend && .venv/bin/python -m pytest tests/test_tasks.py::test_future_roles_cannot_view_unassigned_task_or_submissions -q +cd backend && .venv/bin/python -m pytest tests/test_projects.py::test_source_snapshot_rejects_unsafe_refs -q ``` Results: - Ruff: passed. -- Docstring coverage: 100.0%. - Stale wording scan: passed. -- Markdown link check: passed for 9 changed Markdown files. +- Markdown link check: passed for 24 changed Markdown files. - Diff whitespace check: passed. -- Migration/actor/auth tests: 35 passed in 518.35s. -- Task tests: 69 passed in 1184.65s. -- Week 1 demo UI build: passed. -- Stale demo/profile scan: no matches. +- Agent gate tests: 26 passed. +- Migration/actor/auth tests: 41 passed in 385.17s. +- Task eligibility regression: 3 passed in 139.46s. +- Project source-ref regression: 1 passed in 79.77s. - Local XLSX export: not present. ## Remaining Risks diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md index 857012a..bfaf38d 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md @@ -19,7 +19,7 @@ Chunk contract: ## What Changed - Added `actor_identities` and `actor_profiles` with async SQLAlchemy models, repository, schemas, service, and Alembic migration. -- Backfilled legacy `worker_profiles` and `reviewer_profiles` into the shared actor registry and removed the old profile tables as profile authority. +- Created the shared actor registry and dropped obsolete `worker_profiles` and `reviewer_profiles` without compatibility backfill. - Kept `get_current_actor` pure and added `get_registered_actor` for explicit registry side effects. - Updated `/auth/me`, `/workers/me/profile`, and task claim paths to register actor identity/profile metadata where needed. - Made worker claim require current verified `worker` token role plus active `ActorProfile(profile_type="worker")`. @@ -52,7 +52,7 @@ The live Terminal Benchmark drill proved worker profile setup must be real. Doin ## Scope Control -Implemented only actor identity/profile registry, migration/backfill/removal, touched-route registration, worker profile activation, worker claim eligibility, tests, demo/script cleanup required by the migration, and documentation alignment. +Implemented only actor identity/profile registry, destructive removal of obsolete profile stores, touched-route registration, worker profile activation, worker claim eligibility, tests, demo/script cleanup required by the migration, and documentation alignment. No Workstream-owned login/signup/session/password behavior was added. No post-submit, review, revision, payment, reputation, blockchain, object storage, agent runtime, or frontend product implementation was added. @@ -66,8 +66,8 @@ No Workstream-owned login/signup/session/password behavior was added. No post-su ## Acceptance Criteria Proof - Migration creates actor registry tables and uniqueness constraints. -- Migration backfills worker/reviewer rows and removes old profile tables. -- Downgrade restores legacy rows with identity/profile data. +- Migration creates actor registry tables and removes old worker/reviewer profile tables without compatibility backfill. +- Downgrade restores the old table shape only; obsolete experimental data is not preserved by this chunk. - SQLAlchemy metadata imports new actor models and has negative assertions for old profile exports. - `/auth/me` registers identity/profile metadata without Workstream auth sessions. - Repeated requests refresh identity/profile freshness without duplicate rows. @@ -82,33 +82,31 @@ No Workstream-owned login/signup/session/password behavior was added. No post-su ## Tests/Checks Run ```bash -cd backend && .venv/bin/python -m ruff check app/api/deps/auth.py app/api/routes/auth.py app/modules/actors app/modules/tasks/models.py app/modules/tasks/repository.py app/modules/tasks/router.py app/modules/tasks/schemas.py app/modules/tasks/service.py tests/test_actors.py tests/test_alembic.py tests/test_auth.py tests/test_tasks.py -cd backend && .venv/bin/docstr-coverage app/api app/modules/actors app/modules/tasks --config .docstr.yaml +cd backend && .venv/bin/python -m ruff check app/modules/actors/service.py app/modules/projects/service.py tests/test_actors.py tests/test_tasks.py python3 scripts/check_stale_workstream_wording.py python3 scripts/check_markdown_links.py -git diff --check +git diff --check origin/main...HEAD +python3 scripts/test_agent_gates.py cd backend && .venv/bin/python -m pytest tests/test_alembic.py tests/test_actors.py tests/test_auth.py -q -cd backend && .venv/bin/python -m pytest tests/test_tasks.py -q -cd demos/week1_api_demo_ui && npm run build -rg -n 'worker_profile_setup=demo_bootstrap|demo worker profile|Activates demo worker profile|WORKSTREAM_ENABLE_DEMO_ROUTES|/api/v1/demo/worker-profile|WorkerProfile|ReviewerProfile' backend/scripts examples/terminal_benchmark backend/app/api/routes/demo.py backend/app/modules README.md demos/week1_api_demo_ui/src/App.tsx docs/spec_chunk_2_auth_actor_boundary.md docs/architecture_data_model.md docs/operations_roles_permissions.md +cd backend && .venv/bin/python -m pytest tests/test_tasks.py::test_future_roles_cannot_view_unassigned_task_or_submissions -q +cd backend && .venv/bin/python -m pytest tests/test_projects.py::test_source_snapshot_rejects_unsafe_refs -q ``` Result summary: - Ruff: passed. -- Docstring coverage: 100.0%. - Stale wording scan: passed. -- Markdown link check: passed for 9 changed Markdown files. +- Markdown link check: passed for 24 changed Markdown files. - Diff whitespace check: passed. -- Migration/actor/auth tests: 35 passed in 518.35s. -- Task tests: 69 passed in 1184.65s. -- Week 1 demo UI build: passed. -- Stale demo/profile scan: no matches. +- Agent gate tests: 26 passed. +- Migration/actor/auth tests: 41 passed in 385.17s. +- Task eligibility regression: 3 passed in 139.46s. +- Project source-ref regression: 1 passed in 79.77s. ## Test Delta - Tests added: `backend/tests/test_actors.py`. -- Tests expanded: Alembic backfill/downgrade/uniqueness, auth registration, task metadata, token-role authorization, overposting, active-profile eligibility, and stale route removal. +- Tests expanded: Alembic destructive removal/downgrade/uniqueness, auth registration, task metadata, token-role authorization, overposting, active-profile eligibility, and stale route removal. - Tests removed/skipped: none. ## CI Integrity @@ -125,16 +123,16 @@ Internal review evidence: - `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md` -Reviewed code SHA: `912a1bef3ce7065e9563a03a440b24efe6af3f89` +Reviewed code SHA: `ce57958666bb28c9112b6513ec16f504c5fd1571` Reviewer run IDs: see `WS-POL-001-11-internal-review-evidence.md`. | Reviewer | Result | Blocking findings | Notes | |---|---:|---|---| -| senior engineering | PASS AFTER FIXES | None | Fixed profile freshness, identity upsert churn, and documented audit ledger coupling. | -| QA/test | PASS | None | Confirmed actor registry behavior, migration, auth boundary, eligibility gates, and demo rewiring. | -| security/auth | PASS AFTER FIXES | None | Confirmed token authority and fail-closed scope after fixes. | -| product/ops | PASS | None | Confirmed actor/profile workflow semantics. | +| senior engineering | PASS AFTER FIXES | None | Required stale evidence wording to be fixed so the PR no longer claims compatibility backfill or deleted demo UI proof. | +| QA/test | PASS WITH LOW RISKS | None | Required stale trust-bundle proof wording to be fixed; no code/test blockers found. | +| security/auth | PASS | None | Confirmed no valid security/auth findings on the final code SHA. | +| product/ops | PASS AFTER FIXES | None | Required stale backfill wording to be fixed so operator expectations match destructive removal/no compatibility backfill. | | architecture | PASS WITH LOW RISKS | None | Confirmed auth/profile boundaries; future shared audit module noted as follow-up. | | CI integrity | PASS | None | Confirmed the post-PR lint fix only imports `uuid4` and does not weaken CI or tests. | | docs | PASS WITH LOW RISKS | None | Confirmed docs alignment after final issuer-plus-subject wording. | @@ -162,7 +160,7 @@ External review has not run yet. CodeRabbit and GitHub checks should run after t Please inspect: - Flow auth boundary: token role first, profile eligibility second. -- Migration/backfill/removal of old worker/reviewer profile stores. +- Destructive removal of old worker/reviewer profile stores with no compatibility backfill. - `ActorProfile` status semantics and preservation of explicit profile metadata. - Overposting protections around registry writes. - Demo/script cleanup from demo bootstrap to canonical worker profile API. diff --git a/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md b/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md index a471cea..268b245 100644 --- a/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md +++ b/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md @@ -51,8 +51,7 @@ Security re-review confirmed: - non-Postgres URL is blocked - local async Postgres URLs for `workstream` and `workstream_test` are allowed - Flow-token auth remains in use -- superseded: the local demo worker-profile route was later removed; current - drills use `POST /api/v1/workers/me/profile` +- demo worker-profile route remains local/test gated - worker redaction and reviewer denial are asserted - no production route or checker-read permission is widened @@ -61,8 +60,7 @@ Security re-review confirmed: Findings: - no blocking findings -- superseded: the demo worker-profile helper was removed; current drills use - the canonical worker profile API +- the demo worker-profile helper is acceptable for v0.1 drills but should not be described as production worker onboarding - uppercase lifecycle labels must not drift into persisted token contracts Resolution: roadmap wording keeps Week 3 readiness explicit and does not overclaim reviewer checker visibility. @@ -107,7 +105,7 @@ cd backend && .venv/bin/python -m ruff check app tests scripts Passed: ```bash -cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py +cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/week1_api_e2e.py ``` Passed: diff --git a/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md b/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md index 52def0d..d87a866 100644 --- a/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md +++ b/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md @@ -117,10 +117,10 @@ Result: `All checks passed!` Passed: ```bash -cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py +cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/week1_api_e2e.py ``` -Result: `API contract e2e passed` and `PASS API contract database invariants`. +Result: `Week 1 real API e2e passed` and `PASS Week 1 database invariants`. Passed: From a008cf81519913f1ec2f6ffe530c0598f8df087e Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 7 Jul 2026 05:07:28 +0100 Subject: [PATCH 18/25] Address actor registry review gaps --- backend/tests/test_auth.py | 1 + backend/tests/test_tasks.py | 27 +++++++++++++++++++ docs/architecture_data_model.md | 5 ++++ ...026-06-12_week2_closeout_real_api_drill.md | 8 +++--- ...-13_week1_week2_deterministic_hardening.md | 4 +-- scripts/check_internal_review_evidence.py | 6 ++--- scripts/test_agent_gates.py | 14 ++++++++-- 7 files changed, 54 insertions(+), 11 deletions(-) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index fef46ae..d721515 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -309,6 +309,7 @@ async def test_no_local_login_password_or_session_routes() -> None: } assert "/api/v1/auth/me" in paths + assert "/api/v1/demo/worker-profile" not in paths assert not any( segment in forbidden_segments for path in paths diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index c4dcd5b..10e55cc 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -1350,6 +1350,33 @@ async def test_worker_without_profile_cannot_claim_ready_task( assert "active worker profile" in response.json()["detail"] +async def test_disabled_worker_profile_cannot_claim_ready_task( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = await create_active_project(task_client) + ready_task = await create_ready_task(task_client, project["id"]) + await seed_actor_profile("disabled-worker", profile_type="worker", status="disabled") + set_dev_actor(monkeypatch, roles="worker", subject="disabled-worker") + + response = await task_client.post( + f"/api/v1/tasks/{ready_task['id']}/claim", + headers=auth_headers(), + json={"reason": "claim with disabled profile"}, + ) + + assert response.status_code == 403 + assert "active worker profile" in response.json()["detail"] + async with db_session.get_session_factory()() as session: + assignment = await session.scalar( + select(TaskAssignment).where(TaskAssignment.task_id == ready_task["id"]) + ) + task = await session.get(WorkstreamTask, ready_task["id"]) + assert assignment is None + assert task is not None + assert task.status == "ready" + + async def test_active_worker_profile_without_worker_token_cannot_claim( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index ade1ed7..f3bae90 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -118,6 +118,11 @@ worker or reviewer profile `active`. Profile status can satisfy workflow eligibility only when product authorization for the current request has already passed. +The actor registry migration is intentionally destructive for the earlier +experimental `worker_profiles` and `reviewer_profiles` tables. Those obsolete +stores are dropped without compatibility backfill. Downgrade restores table +shape only; it does not preserve old experimental profile data. + `project_owner` is a scoped profile/contact relationship, not a route role. In this chunk it is created from trusted relationship claims when present. Later project setup/source-contact workflows may create the same scoped profile type diff --git a/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md b/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md index 268b245..a471cea 100644 --- a/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md +++ b/docs/internal_reviews/2026-06-12_week2_closeout_real_api_drill.md @@ -51,7 +51,8 @@ Security re-review confirmed: - non-Postgres URL is blocked - local async Postgres URLs for `workstream` and `workstream_test` are allowed - Flow-token auth remains in use -- demo worker-profile route remains local/test gated +- superseded: the local demo worker-profile route was later removed; current + drills use `POST /api/v1/workers/me/profile` - worker redaction and reviewer denial are asserted - no production route or checker-read permission is widened @@ -60,7 +61,8 @@ Security re-review confirmed: Findings: - no blocking findings -- the demo worker-profile helper is acceptable for v0.1 drills but should not be described as production worker onboarding +- superseded: the demo worker-profile helper was removed; current drills use + the canonical worker profile API - uppercase lifecycle labels must not drift into persisted token contracts Resolution: roadmap wording keeps Week 3 readiness explicit and does not overclaim reviewer checker visibility. @@ -105,7 +107,7 @@ cd backend && .venv/bin/python -m ruff check app tests scripts Passed: ```bash -cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/week1_api_e2e.py +cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py ``` Passed: diff --git a/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md b/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md index d87a866..52def0d 100644 --- a/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md +++ b/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md @@ -117,10 +117,10 @@ Result: `All checks passed!` Passed: ```bash -cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/week1_api_e2e.py +cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py ``` -Result: `Week 1 real API e2e passed` and `PASS Week 1 database invariants`. +Result: `API contract e2e passed` and `PASS API contract database invariants`. Passed: diff --git a/scripts/check_internal_review_evidence.py b/scripts/check_internal_review_evidence.py index c0beb28..f2930f4 100644 --- a/scripts/check_internal_review_evidence.py +++ b/scripts/check_internal_review_evidence.py @@ -171,8 +171,6 @@ def add(track: str) -> None: def is_internal_review_evidence_path(path: str) -> bool: """Return whether path is an internal reviewer evidence file.""" - if path.startswith("docs/internal_reviews/") and path.endswith(".md"): - return True return ( path.startswith(".agent-loop/initiatives/") and "/reviews/" in path @@ -415,8 +413,8 @@ def main() -> int: print( "Internal review evidence is required for engineering-loop, process, " "or implementation changes.\n" - "Add a changed docs/internal_reviews/*.md file or " - ".agent-loop/initiatives//reviews/*-internal-review-evidence.md file with these " + "Add a changed .agent-loop/initiatives//reviews/" + "*-internal-review-evidence.md file with these " f"reviewer tracks before opening the PR: {', '.join(required_tracks)}.", file=sys.stderr, ) diff --git a/scripts/test_agent_gates.py b/scripts/test_agent_gates.py index ec32fd8..1b8c9bb 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -73,6 +73,7 @@ def test_review_evidence_files_are_not_relevant_changes() -> None: gate = load_module("review_gate_relevance", "scripts/check_internal_review_evidence.py") assert not gate.is_relevant(".agent-loop/initiatives/example/reviews/review.md") assert not gate.is_relevant("docs/internal_reviews/example.md") + assert not gate.is_internal_review_evidence_path("docs/internal_reviews/example.md") assert gate.is_internal_review_evidence_path( ".agent-loop/initiatives/example/reviews/example-internal-review-evidence.md" ) @@ -440,7 +441,11 @@ def test_evidence_main_passes_with_complete_evidence_and_pr_head() -> None: original_changed_files = gate.changed_files reviewed = "a" * 40 local_head = "b" * 40 - evidence = ROOT / "docs/internal_reviews/test_agent_gate_complete_evidence.md" + evidence = ( + ROOT + / ".agent-loop/initiatives/test-agent-gate/" + "reviews/test-agent-gate-internal-review-evidence.md" + ) def fake_git(*args: str) -> str: if args == ("merge-base", "--is-ancestor", "origin/main", "HEAD"): @@ -461,13 +466,16 @@ def fake_git(*args: str) -> str: gate.git_ok = lambda *args: True gate.changed_files = lambda: [ "scripts/check_internal_review_evidence.py", - "docs/internal_reviews/test_agent_gate_complete_evidence.md", + ".agent-loop/initiatives/test-agent-gate/" + "reviews/test-agent-gate-internal-review-evidence.md", + "docs/internal_reviews/historical-note.md", ".agent-loop/initiatives/example/reviews/example-external-review-response.md", ] try: os.environ.pop("INTERNAL_REVIEW_BASE_REF", None) os.environ["INTERNAL_REVIEW_CHUNK_ID"] = "WS-ENG-001-01" os.environ["PR_HEAD_SHA"] = reviewed + evidence.parent.mkdir(parents=True, exist_ok=True) evidence.write_text( "WS-ENG-001-01\n" "open sub-agent sessions: none\n" @@ -492,6 +500,8 @@ def fake_git(*args: str) -> str: gate.git_ok = original_git_ok gate.changed_files = original_changed_files evidence.unlink(missing_ok=True) + evidence.parent.rmdir() + evidence.parent.parent.rmdir() for key, value in original_env.items(): if value is None: os.environ.pop(key, None) From 3b2977e99c05a2d0b229e807d9ee6e72abaaf6b4 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 7 Jul 2026 05:15:13 +0100 Subject: [PATCH 19/25] Finalize actor registry review evidence --- .../WS-POL-001-11-internal-review-evidence.md | 39 ++++++++++++------- .../reviews/WS-POL-001-11-pr-trust-bundle.md | 32 ++++++++------- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md index 9cb944f..e4ce0a2 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md @@ -10,11 +10,11 @@ valid findings addressed: yes ## Reviewed Revision -Reviewed code SHA: ce57958666bb28c9112b6513ec16f504c5fd1571 +Reviewed code SHA: a008cf81519913f1ec2f6ffe530c0598f8df087e -Reviewed at: 2026-07-07T03:45:00Z +Reviewed at: 2026-07-07T04:12:04Z -Reviewer run IDs: senior-engineering-review-019f3aa7-a13d-7052-85dc-635cbfa7dadb, qa-test-review-019f3aa7-a9a4-7e80-9fb5-8b9961b0c913, security-auth-review-019f3aa7-b1b3-73f2-959e-9eaae25fabd3, product-ops-review-019f3aa7-bce6-77b1-9053-09926c54b4c9 +Reviewer run IDs: senior-engineering-review-019f3aa7-a13d-7052-85dc-635cbfa7dadb, qa-test-review-019f3aa7-a9a4-7e80-9fb5-8b9961b0c913, qa-test-rerun-019f3ac2-c5d7-7e90-947f-85309bd89808, security-auth-review-019f3aa7-b1b3-73f2-959e-9eaae25fabd3, product-ops-review-019f3aa7-bce6-77b1-9053-09926c54b4c9, architecture-review-019f3ab2-f368-7f03-bff7-29999e5a076f, ci-integrity-review-019f3ab2-f9fd-7263-82a3-d6fdf9d3967b, ci-integrity-rerun-019f3ac2-d856-7c80-9bcf-63f5c5b71c2d, docs-review-019f3ab3-0752-7391-adac-3a72c9af37ba, docs-rerun-019f3ac2-ce8e-7c12-9bbe-c3ffc54d1166, reuse-dedup-review-019f3ab3-12ad-7c92-86e7-eee531d82a6a, reuse-dedup-rerun-019f3ac2-e587-7d33-a978-57734c17d681, test-delta-review-019f3ab3-2436-7b82-9e17-7dd083ef1afa, test-delta-rerun-019f3ac2-fb49-7530-968f-50cd1b79041b After the reviewed SHA, only evidence and status files changed. @@ -26,6 +26,7 @@ Scope: - Keeps `get_current_actor` as the pure Flow-token boundary and adds `get_registered_actor` for explicit registry side effects. - Creates new `actor_identities` and `actor_profiles` tables, then drops obsolete `worker_profiles` and `reviewer_profiles` without compatibility backfill. - Makes worker profile activation write `ActorProfile(profile_type="worker", status="active")` through the actor module. +- Applies registration side effects to `/auth/me`, worker profile setup, project routes, checker routes, and task routes touched by this chunk. - Makes task claim require both a verified `worker` token role and an active worker profile. - Preserves active/disabled profile metadata during token observation refreshes. - Keeps persisted profiles as workflow eligibility/audit records, never route permission authority. @@ -37,14 +38,14 @@ Scope: | Reviewer | Result | Blocking findings | Notes | |---|---:|---|---| | senior engineering | PASS AFTER FIXES | None | Required stale evidence wording to be fixed so the PR no longer claims compatibility backfill or deleted demo UI proof. | -| QA/test | PASS WITH LOW RISKS | None | Required stale trust-bundle proof wording to be fixed; no code/test blockers found. | +| QA/test | PASS AFTER FIXES | None | Added disabled-worker claim denial and deleted demo-route regression coverage. | | security/auth | PASS | None | Confirmed no valid security/auth findings on the final code SHA. | | product/ops | PASS AFTER FIXES | None | Required stale backfill wording to be fixed so operator expectations match destructive removal/no compatibility backfill. | | architecture | PASS WITH LOW RISKS | None | Confirmed Flow auth boundary, actor/profile non-auth semantics, worker claim gate, and documented v0.1 audit ledger coupling. | -| CI integrity | PASS | None | Confirmed the post-PR lint fix only adds the missing `uuid4` import and does not weaken CI, lint, tests, typecheck, coverage, workflows, or package scripts. | -| docs | PASS WITH LOW RISKS | None | Confirmed docs align after adding `workstream_relationship_profiles` schema, audit schema, demo cleanup, and issuer-plus-subject wording. | -| reuse/dedup | PASS | None | Confirmed `ActorService` is profile authority, audit writes reuse `TaskRepository.add_audit_event`, and old profile authority paths are removed. | -| test delta | PASS | None | Confirmed persisted overposting assertions, downgrade restore assertions, metadata negative assertions, and active/disabled metadata preservation coverage. | +| CI integrity | PASS AFTER FIXES | None | Fixed stale reviewed-SHA evidence and made `.agent-loop` review evidence the canonical gate input; historical `docs/internal_reviews` notes are docs. | +| docs | PASS AFTER FIXES | None | Added destructive migration note and marked old demo/Week 1 internal-review references as superseded. | +| reuse/dedup | PASS AFTER FIXES | None | Added full reviewer provenance and corrected route-registration scope in evidence. | +| test delta | PASS AFTER FIXES | None | Added disabled-profile claim denial, deleted demo-route assertion, and gate behavior coverage. | ## Valid Findings Addressed @@ -61,18 +62,25 @@ Scope: - Updated audit-event docs to match actual `actor_roles`, `from_status`, `to_status`, `is_dev_auth`, and `event_payload` fields. - Aligned Flow Identity wording so docs consistently name Flow issuer plus subject as the canonical identity anchor. - Fixed stale evidence after final internal review so the trust bundle no longer claims compatibility backfill or deleted demo UI proof. +- Added a disabled-worker-profile claim regression proving disabled profiles cannot satisfy worker eligibility. +- Added a route-regression assertion proving `/api/v1/demo/worker-profile` is no longer mounted. +- Documented destructive actor-profile migration behavior in the public data model. +- Updated historical internal-review notes to mark removed demo helper behavior as superseded. +- Changed the internal-review evidence gate so ordinary `docs/internal_reviews` notes are documentation, while current gate-satisfying evidence must live in `.agent-loop/...-internal-review-evidence.md`. +- Updated this evidence with full reviewer run IDs and project/checker route-registration scope. ## Commands Run ```bash -cd backend && .venv/bin/python -m ruff check app/modules/actors/service.py app/modules/projects/service.py tests/test_actors.py tests/test_tasks.py +cd backend && .venv/bin/python -m ruff check tests/test_auth.py tests/test_tasks.py python3 scripts/check_stale_workstream_wording.py python3 scripts/check_markdown_links.py git diff --check origin/main...HEAD python3 scripts/test_agent_gates.py +python3 scripts/check_internal_review_evidence.py cd backend && .venv/bin/python -m pytest tests/test_alembic.py tests/test_actors.py tests/test_auth.py -q -cd backend && .venv/bin/python -m pytest tests/test_tasks.py::test_future_roles_cannot_view_unassigned_task_or_submissions -q -cd backend && .venv/bin/python -m pytest tests/test_projects.py::test_source_snapshot_rejects_unsafe_refs -q +cd backend && .venv/bin/python -m pytest tests/test_auth.py::test_no_local_login_password_or_session_routes -q +cd backend && .venv/bin/python -m pytest tests/test_tasks.py::test_disabled_worker_profile_cannot_claim_ready_task tests/test_tasks.py::test_worker_without_profile_cannot_claim_ready_task -q ``` Results: @@ -82,13 +90,14 @@ Results: - Markdown link check: passed for 24 changed Markdown files. - Diff whitespace check: passed. - Agent gate tests: 26 passed. -- Migration/actor/auth tests: 41 passed in 385.17s. -- Task eligibility regression: 3 passed in 139.46s. -- Project source-ref regression: 1 passed in 79.77s. +- Internal review evidence gate: passed after this evidence update. +- Migration/actor/auth tests: 41 passed in 348.89s. +- Demo route regression: 1 passed in 14.77s. +- Task eligibility regressions: 2 passed in 84.84s. - Local XLSX export: not present. ## Remaining Risks - Actor profile audit writes use the existing task-owned audit ledger helper in v0.1. This keeps one audit source of truth for now, but a future shared audit module should extract the code boundary before actor/reputation work grows. -- Existing routes outside the chunk may continue using pure `get_current_actor` until deliberately migrated. This chunk only adds registration side effects to `/auth/me`, worker profile setup, and task claim paths touched here. +- Existing routes outside the chunk may continue using pure `get_current_actor` until deliberately migrated. This chunk adds registration side effects to `/auth/me`, worker profile setup, project routes, checker routes, and task routes touched here. - The next Terminal Benchmark live API drill still needs to run through real HTTP calls against this implementation after PR review. diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md index bfaf38d..d379fd9 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md @@ -21,7 +21,7 @@ Chunk contract: - Added `actor_identities` and `actor_profiles` with async SQLAlchemy models, repository, schemas, service, and Alembic migration. - Created the shared actor registry and dropped obsolete `worker_profiles` and `reviewer_profiles` without compatibility backfill. - Kept `get_current_actor` pure and added `get_registered_actor` for explicit registry side effects. -- Updated `/auth/me`, `/workers/me/profile`, and task claim paths to register actor identity/profile metadata where needed. +- Updated `/auth/me`, `/workers/me/profile`, project routes, checker routes, and task paths touched by this chunk to register actor identity/profile metadata where needed. - Made worker claim require current verified `worker` token role plus active `ActorProfile(profile_type="worker")`. - Preserved active/disabled profile provenance during token observation refreshes. - Retired stale demo worker-profile bootstrap paths and rewired scripts/examples/UI to `POST /api/v1/workers/me/profile`. @@ -82,14 +82,15 @@ No Workstream-owned login/signup/session/password behavior was added. No post-su ## Tests/Checks Run ```bash -cd backend && .venv/bin/python -m ruff check app/modules/actors/service.py app/modules/projects/service.py tests/test_actors.py tests/test_tasks.py +cd backend && .venv/bin/python -m ruff check tests/test_auth.py tests/test_tasks.py python3 scripts/check_stale_workstream_wording.py python3 scripts/check_markdown_links.py git diff --check origin/main...HEAD python3 scripts/test_agent_gates.py +python3 scripts/check_internal_review_evidence.py cd backend && .venv/bin/python -m pytest tests/test_alembic.py tests/test_actors.py tests/test_auth.py -q -cd backend && .venv/bin/python -m pytest tests/test_tasks.py::test_future_roles_cannot_view_unassigned_task_or_submissions -q -cd backend && .venv/bin/python -m pytest tests/test_projects.py::test_source_snapshot_rejects_unsafe_refs -q +cd backend && .venv/bin/python -m pytest tests/test_auth.py::test_no_local_login_password_or_session_routes -q +cd backend && .venv/bin/python -m pytest tests/test_tasks.py::test_disabled_worker_profile_cannot_claim_ready_task tests/test_tasks.py::test_worker_without_profile_cannot_claim_ready_task -q ``` Result summary: @@ -99,14 +100,15 @@ Result summary: - Markdown link check: passed for 24 changed Markdown files. - Diff whitespace check: passed. - Agent gate tests: 26 passed. -- Migration/actor/auth tests: 41 passed in 385.17s. -- Task eligibility regression: 3 passed in 139.46s. -- Project source-ref regression: 1 passed in 79.77s. +- Internal review evidence gate: passed after evidence update. +- Migration/actor/auth tests: 41 passed in 348.89s. +- Demo route regression: 1 passed in 14.77s. +- Task eligibility regressions: 2 passed in 84.84s. ## Test Delta - Tests added: `backend/tests/test_actors.py`. -- Tests expanded: Alembic destructive removal/downgrade/uniqueness, auth registration, task metadata, token-role authorization, overposting, active-profile eligibility, and stale route removal. +- Tests expanded: Alembic destructive removal/downgrade/uniqueness, auth registration, task metadata, token-role authorization, overposting, active/disabled-profile eligibility, stale route removal, and internal-review evidence gate behavior. - Tests removed/skipped: none. ## CI Integrity @@ -123,21 +125,21 @@ Internal review evidence: - `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md` -Reviewed code SHA: `ce57958666bb28c9112b6513ec16f504c5fd1571` +Reviewed code SHA: `a008cf81519913f1ec2f6ffe530c0598f8df087e` Reviewer run IDs: see `WS-POL-001-11-internal-review-evidence.md`. | Reviewer | Result | Blocking findings | Notes | |---|---:|---|---| | senior engineering | PASS AFTER FIXES | None | Required stale evidence wording to be fixed so the PR no longer claims compatibility backfill or deleted demo UI proof. | -| QA/test | PASS WITH LOW RISKS | None | Required stale trust-bundle proof wording to be fixed; no code/test blockers found. | +| QA/test | PASS AFTER FIXES | None | Added disabled-worker claim denial and deleted demo-route regression coverage. | | security/auth | PASS | None | Confirmed no valid security/auth findings on the final code SHA. | | product/ops | PASS AFTER FIXES | None | Required stale backfill wording to be fixed so operator expectations match destructive removal/no compatibility backfill. | | architecture | PASS WITH LOW RISKS | None | Confirmed auth/profile boundaries; future shared audit module noted as follow-up. | -| CI integrity | PASS | None | Confirmed the post-PR lint fix only imports `uuid4` and does not weaken CI or tests. | -| docs | PASS WITH LOW RISKS | None | Confirmed docs alignment after final issuer-plus-subject wording. | -| reuse/dedup | PASS | None | Confirmed single actor/profile authority and audit helper reuse. | -| test delta | PASS | None | Confirmed tests were strengthened and not weakened. | +| CI integrity | PASS AFTER FIXES | None | Fixed stale reviewed-SHA evidence and kept `.agent-loop` review evidence as the canonical gate input. | +| docs | PASS AFTER FIXES | None | Added destructive migration note and marked old demo/Week 1 internal-review references as superseded. | +| reuse/dedup | PASS AFTER FIXES | None | Added full reviewer provenance and corrected route-registration scope in evidence. | +| test delta | PASS AFTER FIXES | None | Added disabled-profile claim denial, deleted demo-route assertion, and gate behavior coverage. | ## External Review @@ -146,7 +148,7 @@ External review has not run yet. CodeRabbit and GitHub checks should run after t ## Remaining Risks - Actor profile audit persistence currently imports the existing task audit repository. This keeps one v0.1 audit ledger but should be extracted to a shared audit module before actor/reputation work grows. -- Existing routes outside this chunk may continue using pure `get_current_actor` until deliberately migrated. +- Existing routes outside this chunk may continue using pure `get_current_actor` until deliberately migrated; this chunk deliberately registers actor context on `/auth/me`, worker profile setup, project routes, checker routes, and touched task routes. - The next Terminal Benchmark live API drill still needs to run against this implementation through real HTTP calls after merge. ## Follow-Up Work From 85ef5bda46634d541f788f18e9090e675d73ba73 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 7 Jul 2026 05:30:18 +0100 Subject: [PATCH 20/25] Fix actor registry CI and role wording --- ...-001-11-actor-identity-profile-registry.md | 19 +++++++++++-------- .../reviews/WS-POL-001-11-pr-trust-bundle.md | 4 ++-- backend/scripts/api_contract_e2e.py | 2 +- docs/architecture_data_model.md | 10 +++++----- docs/glossary.md | 9 +++++---- docs/operations_roles_permissions.md | 14 ++++++++------ 6 files changed, 32 insertions(+), 26 deletions(-) diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md index 129ef2f..737491c 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md @@ -32,11 +32,13 @@ Flow token verification `ActorIdentity` and `ActorProfile` are not authentication. Flow remains the auth provider. Workstream still does not own login, signup, password reset, password -storage, or primary auth sessions. Workstream owns product authorization and -the future role-assignment API keyed by issuer plus subject; this chunk only -adds the shared identity/profile registry needed before that layer. In the -v0.1 bootstrap, route access may still use trusted roles from the verified -`ActorContext` until Workstream-owned role assignment records are implemented. +storage, or primary auth sessions. Workstream owns product authorization. A +later Workstream-owned role-assignment layer will store product roles on local +`ActorIdentity` records derived from verified Flow issuer plus subject; this +chunk only adds the shared identity/profile registry needed before that layer. +In the v0.1 bootstrap, route access may still use trusted roles from the +verified `ActorContext` until Workstream-owned role assignment records are +implemented. Persisted `ActorProfile.profile_type` is metadata, eligibility, audit context, and later routing/reputation context. It may be required as an additional @@ -273,9 +275,10 @@ This must be fail-closed: Route authorization currently checks trusted request roles from `ActorContext` as the v0.1 bootstrap path. The Identity Issuer is not the canonical source of -Workstream product roles; the later Workstream role-assignment API must own that -authorization state keyed to `ActorIdentity`. Profile checks are additional -workflow eligibility checks, not route permissions. +Workstream product roles; the later Workstream-owned role-assignment layer must +store and enforce that authorization state on local `ActorIdentity` records. +Profile checks are additional workflow eligibility checks, not route +permissions. Examples: diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md index d379fd9..dda8da7 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md @@ -10,7 +10,7 @@ Implement Workstream's local actor identity and shared actor profile registry fo ## Human-Approved Intent -The user asked for one shared actor/profile model instead of separate worker, reviewer, admin, project-manager, and project-owner implementations. The verified Flow token remains the authority for route access. Workstream stores local actor/profile rows for workflow eligibility, audit, display, future routing, and later reputation linkage. +The user asked for one shared actor/profile model instead of separate worker, reviewer, admin, project-manager, and project-owner implementations. During the v0.1 bootstrap, trusted roles in the verified `ActorContext` remain the request-time route gate until Workstream-owned role assignment exists. Workstream stores local actor/profile rows for workflow eligibility, audit, display, future routing, and later reputation linkage. Chunk contract: @@ -45,7 +45,7 @@ The live Terminal Benchmark drill proved worker profile setup must be real. Doin ## Alternatives Rejected - Separate profile tables per role: rejected because it duplicates profile authority. -- Persisted profiles as route permissions: rejected because Flow token claims are the route authorization source. +- Persisted profiles as route permissions: rejected because profile rows are workflow metadata/eligibility, not product authorization. - Automatic worker/reviewer eligibility from token observation: rejected because eligibility must come from explicit profile workflows. - Keeping old worker/reviewer compatibility stores: rejected because v0.1 is still under construction and should not preserve stale authority. - A new audit table/module in this chunk: deferred because the current accepted boundary is one v0.1 audit ledger, with extraction documented for future actor/reputation expansion. diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index e730bae..21830de 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -584,7 +584,7 @@ async def create_policy_bundle_for_guide( "items": [ { "source_kind": "inline_markdown", - "durable_ref": f"inline:/api-contract/{run_id}/guide", + "durable_ref": f"inline:/guides/{run_id}/guide", "ingestion_adapter": "manual_import", "content_hash": sha256_token(f"{run_id}:guide"), "media_type": "text/markdown", diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index f3bae90..bee34ff 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -64,11 +64,11 @@ must not be treated as the primary identity. Workstream keeps `ActorIdentity` rows for local workflow continuity, audit display, profile linkage, assignments, and later reputation records. It does -not own password authentication or primary login sessions. Workstream does own -product roles and exact resource authorization keyed by issuer plus subject. In -the v0.1 bootstrap, route checks may still read trusted role claims from the -current `ActorContext` until the dedicated Workstream role-assignment API is -introduced. +not own password authentication or primary login sessions. Workstream owns +product roles and exact resource authorization locally. Flow issuer plus subject +identifies the actor; it does not assign Workstream product roles. In the v0.1 +bootstrap, route checks may still read trusted role claims from the current +`ActorContext` until the Workstream-owned role-assignment layer is introduced. Actor registry refresh is bounded by `WORKSTREAM_ACTOR_REGISTRY_REFRESH_INTERVAL_SECONDS`. Workstream verifies the diff --git a/docs/glossary.md b/docs/glossary.md index f555f1b..069562f 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -24,10 +24,11 @@ present in the trusted request context, claim snapshot, auth source, and display metadata. The Flow issuer plus subject is the canonical portable identity anchor; Workstream's actor id is a local durable reference derived from that pair. The Identity Issuer is not the source of truth for -Workstream product roles; Workstream owns exact resource authorization keyed by -issuer plus subject. In the v0.1 bootstrap, route checks may still read trusted -role claims from the current actor context until the Workstream role-assignment -API is introduced. Persisted profile rows are never route permission grants. +Workstream product roles; Workstream stores and enforces product roles locally +for verified Flow subjects. In the v0.1 bootstrap, route checks may still read +trusted role claims from the current actor context until the Workstream-owned +role-assignment layer is introduced. Persisted profile rows are never route +permission grants. ## ActorIdentity diff --git a/docs/operations_roles_permissions.md b/docs/operations_roles_permissions.md index 406d140..cfed524 100644 --- a/docs/operations_roles_permissions.md +++ b/docs/operations_roles_permissions.md @@ -7,16 +7,18 @@ Workstream needs explicit permissions from the first version because review, pay ## Roles The Identity Issuer owns identity, audience, scopes, delegation, and token -signing. Workstream owns product roles and exact resource authorization keyed -by the issuer plus subject. Scopes are an outer request-class gate; they are not -proof that the subject is a reviewer, worker, project owner, or admin inside -Workstream. +signing. Workstream owns product roles and exact resource authorization. +Workstream role records are stored locally for verified Flow subjects; the +issuer plus subject only identifies the actor and does not assign product +roles. Scopes are an outer request-class gate; they are not proof that the +subject is a reviewer, worker, project owner, or admin inside Workstream. In the current v0.1 bootstrap, route checks still read trusted role claims from the verified `ActorContext` where a dedicated Workstream role-assignment table does not exist yet. Those token roles are request context and provisioning -input, not the long-term source of truth. The role-assignment API must become a -Workstream-owned authorization layer keyed to `ActorIdentity`. +input, not the long-term source of truth. The later role-assignment layer must +be Workstream-owned and must store product roles on local `ActorIdentity` +records. Local Workstream `ActorIdentity` and `ActorProfile` records may mirror observed roles, profile state, skill tags, scope, and eligibility metadata, but persisted From afc679f1954ac4f7c84c2e9c0d5d822b99131d94 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 7 Jul 2026 05:43:40 +0100 Subject: [PATCH 21/25] Tighten actor role wording --- backend/scripts/api_contract_e2e.py | 2 +- docs/architecture_system_architecture.md | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 21830de..be5bb00 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -126,7 +126,7 @@ def issue_flow_token( Args: subject: External Flow subject. - roles: Workstream roles granted by Flow for this actor. + roles: Trusted v0.1 bootstrap role claims for this actor. issuer: Flow issuer claim. audience: Flow audience claim. secret: HMAC secret shared with the local Flow verifier. diff --git a/docs/architecture_system_architecture.md b/docs/architecture_system_architecture.md index ba67333..d7bc26e 100644 --- a/docs/architecture_system_architecture.md +++ b/docs/architecture_system_architecture.md @@ -97,10 +97,11 @@ Auth policy: - Workstream may keep local actor/profile records for workflow state, workflow eligibility, audit display, and reputation, but those records do not replace Flow as the auth source and do not grant route access. -- Route role and permission checks use trusted Flow token claims for the - current request. Local actor/profile records may add workflow eligibility - conditions, such as an active worker profile before task claim, but they are - not route permission authority. +- Route role and permission checks may use trusted role claims from the current + `ActorContext` only as the v0.1 bootstrap path. The long-term authorization + source is a Workstream-owned role-assignment layer. Local actor/profile + records may add workflow eligibility conditions, such as an active worker + profile before task claim, but they are not route permission authority. - Routers use the pure current-actor dependency when they only need verified Flow identity. Routes that need local actor registry side effects use a separate registration dependency that first resolves the current actor, then From f1847d59dd2eb19bca5160e0514a8f47d8bc494f Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 7 Jul 2026 05:48:51 +0100 Subject: [PATCH 22/25] Clarify Week 2 role claim wording --- backend/scripts/week2_api_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/week2_api_e2e.py b/backend/scripts/week2_api_e2e.py index 77f7c04..d5e7b65 100644 --- a/backend/scripts/week2_api_e2e.py +++ b/backend/scripts/week2_api_e2e.py @@ -292,7 +292,7 @@ def token_for( Args: subject: External Flow subject. - roles: Flow roles to include in the signed token. + roles: Trusted v0.1 bootstrap role claims for the signed token. issuer: Expected Flow issuer. audience: Expected Flow audience. secret: Local HMAC secret. From 7f6f1978e47c7509bc8acf54b817063d33ec3730 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 7 Jul 2026 05:50:57 +0100 Subject: [PATCH 23/25] Update actor registry CI review evidence --- .../WS-POL-001-11-internal-review-evidence.md | 19 +++++++++++++------ .../reviews/WS-POL-001-11-pr-trust-bundle.md | 12 ++++++++---- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md index e4ce0a2..b43a659 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md @@ -10,11 +10,11 @@ valid findings addressed: yes ## Reviewed Revision -Reviewed code SHA: a008cf81519913f1ec2f6ffe530c0598f8df087e +Reviewed code SHA: f1847d59dd2eb19bca5160e0514a8f47d8bc494f -Reviewed at: 2026-07-07T04:12:04Z +Reviewed at: 2026-07-07T04:49:07Z -Reviewer run IDs: senior-engineering-review-019f3aa7-a13d-7052-85dc-635cbfa7dadb, qa-test-review-019f3aa7-a9a4-7e80-9fb5-8b9961b0c913, qa-test-rerun-019f3ac2-c5d7-7e90-947f-85309bd89808, security-auth-review-019f3aa7-b1b3-73f2-959e-9eaae25fabd3, product-ops-review-019f3aa7-bce6-77b1-9053-09926c54b4c9, architecture-review-019f3ab2-f368-7f03-bff7-29999e5a076f, ci-integrity-review-019f3ab2-f9fd-7263-82a3-d6fdf9d3967b, ci-integrity-rerun-019f3ac2-d856-7c80-9bcf-63f5c5b71c2d, docs-review-019f3ab3-0752-7391-adac-3a72c9af37ba, docs-rerun-019f3ac2-ce8e-7c12-9bbe-c3ffc54d1166, reuse-dedup-review-019f3ab3-12ad-7c92-86e7-eee531d82a6a, reuse-dedup-rerun-019f3ac2-e587-7d33-a978-57734c17d681, test-delta-review-019f3ab3-2436-7b82-9e17-7dd083ef1afa, test-delta-rerun-019f3ac2-fb49-7530-968f-50cd1b79041b +Reviewer run IDs: senior-engineering-review-019f3aa7-a13d-7052-85dc-635cbfa7dadb, senior-engineering-final-019f3ad7-f6ea-7d63-a2ba-614a31be77e1, qa-test-review-019f3aa7-a9a4-7e80-9fb5-8b9961b0c913, qa-test-rerun-019f3ac2-c5d7-7e90-947f-85309bd89808, qa-test-final-019f3ad7-e4ed-78d2-a2f2-cccd1ed13172, security-auth-review-019f3aa7-b1b3-73f2-959e-9eaae25fabd3, security-auth-final-019f3ad7-c49d-7141-95f3-3e7e7e4321e3, security-auth-final-019f3ae4-00fc-7083-8b3f-09e6dfe9cdbc, product-ops-review-019f3aa7-bce6-77b1-9053-09926c54b4c9, product-ops-final-019f3ad7-d48d-7712-8fe6-ac16bc686a9b, product-ops-final-019f3ae3-f393-7e22-85be-f5ddea8bc26f, architecture-review-019f3ab2-f368-7f03-bff7-29999e5a076f, ci-integrity-review-019f3ab2-f9fd-7263-82a3-d6fdf9d3967b, ci-integrity-rerun-019f3ac2-d856-7c80-9bcf-63f5c5b71c2d, ci-integrity-final-019f3ad7-b07d-7b01-9d88-034546c16eb2, ci-integrity-final-019f3ae3-da6f-7522-a591-7aba0b86c168, docs-review-019f3ab3-0752-7391-adac-3a72c9af37ba, docs-rerun-019f3ac2-ce8e-7c12-9bbe-c3ffc54d1166, docs-final-019f3ad7-b67e-7fe3-9601-fb0f8e8557bf, docs-final-019f3ae3-ea83-7fc3-82c2-50ebbef57bb1, reuse-dedup-review-019f3ab3-12ad-7c92-86e7-eee531d82a6a, reuse-dedup-rerun-019f3ac2-e587-7d33-a978-57734c17d681, test-delta-review-019f3ab3-2436-7b82-9e17-7dd083ef1afa, test-delta-rerun-019f3ac2-fb49-7530-968f-50cd1b79041b After the reviewed SHA, only evidence and status files changed. @@ -32,6 +32,7 @@ Scope: - Keeps persisted profiles as workflow eligibility/audit records, never route permission authority. - Rewires demo/scripts/examples from `/api/v1/demo/worker-profile` to `POST /api/v1/workers/me/profile`. - Aligns docs with Flow issuer plus subject as the canonical identity anchor and Workstream actor id as a local durable reference. +- Tightens role wording so Flow issuer plus subject is only the identity anchor; Workstream-owned role assignment is the product-role authority. ## Reviewer Results @@ -40,10 +41,10 @@ Scope: | senior engineering | PASS AFTER FIXES | None | Required stale evidence wording to be fixed so the PR no longer claims compatibility backfill or deleted demo UI proof. | | QA/test | PASS AFTER FIXES | None | Added disabled-worker claim denial and deleted demo-route regression coverage. | | security/auth | PASS | None | Confirmed no valid security/auth findings on the final code SHA. | -| product/ops | PASS AFTER FIXES | None | Required stale backfill wording to be fixed so operator expectations match destructive removal/no compatibility backfill. | +| product/ops | PASS AFTER FIXES | None | Fixed role wording so Identity Issuer does not read as product-role authority. | | architecture | PASS WITH LOW RISKS | None | Confirmed Flow auth boundary, actor/profile non-auth semantics, worker claim gate, and documented v0.1 audit ledger coupling. | -| CI integrity | PASS AFTER FIXES | None | Fixed stale reviewed-SHA evidence and made `.agent-loop` review evidence the canonical gate input; historical `docs/internal_reviews` notes are docs. | -| docs | PASS AFTER FIXES | None | Added destructive migration note and marked old demo/Week 1 internal-review references as superseded. | +| CI integrity | PASS AFTER FIXES | None | Fixed the API contract E2E source-ref namespace and kept source-ref validation intact. | +| docs | PASS AFTER FIXES | None | Tightened Identity Issuer versus Workstream-owned role wording across docs and scripts. | | reuse/dedup | PASS AFTER FIXES | None | Added full reviewer provenance and corrected route-registration scope in evidence. | | test delta | PASS AFTER FIXES | None | Added disabled-profile claim denial, deleted demo-route assertion, and gate behavior coverage. | @@ -68,11 +69,15 @@ Scope: - Updated historical internal-review notes to mark removed demo helper behavior as superseded. - Changed the internal-review evidence gate so ordinary `docs/internal_reviews` notes are documentation, while current gate-satisfying evidence must live in `.agent-loop/...-internal-review-evidence.md`. - Updated this evidence with full reviewer run IDs and project/checker route-registration scope. +- Fixed the API contract E2E source snapshot ref from an unapproved opaque namespace to `inline:/guides/...`. +- Reworded role documentation so v0.1 token roles are bootstrap request context and Workstream remains product-role authority. +- Reworded API drill docstrings so signed-token roles are bootstrap role claims, not roles granted by Flow. ## Commands Run ```bash cd backend && .venv/bin/python -m ruff check tests/test_auth.py tests/test_tasks.py +cd backend && .venv/bin/python -m ruff check scripts/api_contract_e2e.py scripts/week2_api_e2e.py python3 scripts/check_stale_workstream_wording.py python3 scripts/check_markdown_links.py git diff --check origin/main...HEAD @@ -81,6 +86,7 @@ python3 scripts/check_internal_review_evidence.py cd backend && .venv/bin/python -m pytest tests/test_alembic.py tests/test_actors.py tests/test_auth.py -q cd backend && .venv/bin/python -m pytest tests/test_auth.py::test_no_local_login_password_or_session_routes -q cd backend && .venv/bin/python -m pytest tests/test_tasks.py::test_disabled_worker_profile_cannot_claim_ready_task tests/test_tasks.py::test_worker_without_profile_cannot_claim_ready_task -q +cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test WORKSTREAM_TEST_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py ``` Results: @@ -94,6 +100,7 @@ Results: - Migration/actor/auth tests: 41 passed in 348.89s. - Demo route regression: 1 passed in 14.77s. - Task eligibility regressions: 2 passed in 84.84s. +- API contract real API E2E: passed on final patch state. - Local XLSX export: not present. ## Remaining Risks diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md index dda8da7..50790d0 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md @@ -26,6 +26,7 @@ Chunk contract: - Preserved active/disabled profile provenance during token observation refreshes. - Retired stale demo worker-profile bootstrap paths and rewired scripts/examples/UI to `POST /api/v1/workers/me/profile`. - Documented Flow issuer plus subject as the canonical identity anchor; Workstream actor id is a local durable reference. +- Tightened wording so Flow issuer plus subject is only the identity anchor; Workstream-owned role assignment is the product-role authority. - Documented trusted `workstream_relationship_profiles` claim shape for scoped project-owner metadata. ## Why It Changed @@ -83,6 +84,7 @@ No Workstream-owned login/signup/session/password behavior was added. No post-su ```bash cd backend && .venv/bin/python -m ruff check tests/test_auth.py tests/test_tasks.py +cd backend && .venv/bin/python -m ruff check scripts/api_contract_e2e.py scripts/week2_api_e2e.py python3 scripts/check_stale_workstream_wording.py python3 scripts/check_markdown_links.py git diff --check origin/main...HEAD @@ -91,6 +93,7 @@ python3 scripts/check_internal_review_evidence.py cd backend && .venv/bin/python -m pytest tests/test_alembic.py tests/test_actors.py tests/test_auth.py -q cd backend && .venv/bin/python -m pytest tests/test_auth.py::test_no_local_login_password_or_session_routes -q cd backend && .venv/bin/python -m pytest tests/test_tasks.py::test_disabled_worker_profile_cannot_claim_ready_task tests/test_tasks.py::test_worker_without_profile_cannot_claim_ready_task -q +cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test WORKSTREAM_TEST_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py ``` Result summary: @@ -104,6 +107,7 @@ Result summary: - Migration/actor/auth tests: 41 passed in 348.89s. - Demo route regression: 1 passed in 14.77s. - Task eligibility regressions: 2 passed in 84.84s. +- API contract real API E2E: passed on final patch state. ## Test Delta @@ -125,7 +129,7 @@ Internal review evidence: - `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md` -Reviewed code SHA: `a008cf81519913f1ec2f6ffe530c0598f8df087e` +Reviewed code SHA: `f1847d59dd2eb19bca5160e0514a8f47d8bc494f` Reviewer run IDs: see `WS-POL-001-11-internal-review-evidence.md`. @@ -134,10 +138,10 @@ Reviewer run IDs: see `WS-POL-001-11-internal-review-evidence.md`. | senior engineering | PASS AFTER FIXES | None | Required stale evidence wording to be fixed so the PR no longer claims compatibility backfill or deleted demo UI proof. | | QA/test | PASS AFTER FIXES | None | Added disabled-worker claim denial and deleted demo-route regression coverage. | | security/auth | PASS | None | Confirmed no valid security/auth findings on the final code SHA. | -| product/ops | PASS AFTER FIXES | None | Required stale backfill wording to be fixed so operator expectations match destructive removal/no compatibility backfill. | +| product/ops | PASS AFTER FIXES | None | Fixed role wording so Identity Issuer does not read as product-role authority. | | architecture | PASS WITH LOW RISKS | None | Confirmed auth/profile boundaries; future shared audit module noted as follow-up. | -| CI integrity | PASS AFTER FIXES | None | Fixed stale reviewed-SHA evidence and kept `.agent-loop` review evidence as the canonical gate input. | -| docs | PASS AFTER FIXES | None | Added destructive migration note and marked old demo/Week 1 internal-review references as superseded. | +| CI integrity | PASS AFTER FIXES | None | Fixed the API contract E2E source-ref namespace and kept source-ref validation intact. | +| docs | PASS AFTER FIXES | None | Tightened Identity Issuer versus Workstream-owned role wording across docs and scripts. | | reuse/dedup | PASS AFTER FIXES | None | Added full reviewer provenance and corrected route-registration scope in evidence. | | test delta | PASS AFTER FIXES | None | Added disabled-profile claim denial, deleted demo-route assertion, and gate behavior coverage. | From 07295316c5973ea544d766b291d790147bc38920 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 7 Jul 2026 07:25:35 +0100 Subject: [PATCH 24/25] Address actor registry external review --- .agent-loop/REVIEW_LOG.md | 6 +- ...-001-11-actor-identity-profile-registry.md | 3 + .../WS-POL-001-11-external-review-response.md | 53 +++++ .../WS-POL-001-11-internal-review-evidence.md | 4 +- .../reviews/WS-POL-001-11-pr-trust-bundle.md | 17 +- backend/app/modules/actors/service.py | 47 ++--- backend/app/modules/audit/__init__.py | 1 + backend/app/modules/audit/repository.py | 33 +++ backend/app/schemas/auth.py | 46 +++-- backend/tests/test_actors.py | 193 +++++++++++++++++- ...-13_week1_week2_deterministic_hardening.md | 2 +- 11 files changed, 352 insertions(+), 53 deletions(-) create mode 100644 .agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-external-review-response.md create mode 100644 backend/app/modules/audit/__init__.py create mode 100644 backend/app/modules/audit/repository.py diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index d850395..34e4eec 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -325,4 +325,8 @@ issuer-plus-subject identity compatibility wording. Evidence: `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md` -External review response: pending PR review. +External review response: `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-external-review-response.md` + +External review status: CodeRabbit comments triaged; valid findings fixed; +legacy-profile backfill request rejected because it contradicts the +no-backward-compatibility chunk decision. diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md index 737491c..cba3d16 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-11-actor-identity-profile-registry.md @@ -74,6 +74,8 @@ backend/app/adapters/auth/flow.py backend/app/core/config.py backend/app/db/models.py backend/app/schemas/auth.py +backend/app/modules/audit/__init__.py +backend/app/modules/audit/repository.py backend/app/modules/actors/__init__.py backend/app/modules/actors/models.py backend/app/modules/actors/repository.py @@ -97,6 +99,7 @@ backend/scripts/week2_api_e2e.py examples/terminal_benchmark/terminal_benchmark_api_e2e.py examples/terminal_benchmark/LOCAL_VALIDATION_NOTES.md README.md +docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md docs/architecture_data_model.md docs/architecture_brief/workstream_architecture_brief.md docs/architecture_lockdown.md diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-external-review-response.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-external-review-response.md new file mode 100644 index 0000000..79be102 --- /dev/null +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-external-review-response.md @@ -0,0 +1,53 @@ +# External Review Response: WS-POL-001-11 + +## Scope + +This file records CodeRabbit and GitHub review feedback for the actor identity +and profile registry chunk. It is separate from internal reviewer evidence. + +## CodeRabbit Findings + +### Accepted And Fixed + +- `get_registered_actor` failure handling: fixed before this response. Registry + persistence failures now map to structured HTTP errors in the dependency. +- `ActorRepository.upsert_identity` stale ORM row: fixed before this response. + The repository reloads with `populate_existing=True`. +- Actor profile metadata `{}` handling: fixed before this response. Explicit + empty metadata is preserved and covered by regression tests. +- No-op actor observation updates: fixed before this response. Repeated + observation does not rewrite unchanged observed profiles. +- Actor profile constraints duplicated constants: fixed before this response. + The SQLAlchemy model derives check-constraint values from actor constants. +- Redundant identity refresh in worker profile activation: fixed before this + response. The route passes `identity_already_refreshed=True`, and the service + only falls back to identity upsert when no registered identity exists. +- Actor registry hot-path N+1 profile reads: fixed in this response. The + freshness guard now loads all profiles once with `list_profiles(actor_id)`. +- Duplicated trusted relationship claim parsing: fixed in this response. + `normalized_relationship_profile_claims()` is the shared sanitizer for audit + snapshots and actor profile observation. +- Actor audit writes through task repository: fixed in this response. Actor + service now writes through `AuditRepository`. +- Deterministic hardening doc output mismatch: fixed in this response. The + recorded API contract output now includes `API contract real API e2e passed`. + +### Intentionally Rejected + +- Restore legacy `worker_profiles` and `reviewer_profiles` backfill before + dropping the tables: rejected. This chunk intentionally removes obsolete + experimental profile stores without backward compatibility because Workstream + is still in the build phase and the human direction is to strip stale models + rather than preserve them. + +### Deferred + +- Shared migration fixture extraction across `test_auth.py`, `test_actors.py`, + and `test_tasks.py`: deferred. The duplication is low-risk test plumbing and + broad fixture churn is not required to close the actor registry behavior + issues in this chunk. + +## Validation + +Validation commands are recorded in the PR trust bundle and internal review +evidence after the final reviewed SHA is locked. diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md index b43a659..221d3c0 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md @@ -52,7 +52,7 @@ Scope: - Fixed active/disabled profile observation so token-role observation refreshes do not overwrite explicit workflow provenance metadata. - Added regression coverage proving active and disabled worker profile metadata stays `worker_profile_api` after observation. -- Reused `TaskRepository.add_audit_event` for actor profile audit writes instead of adding a parallel audit persistence path. +- Added a shared `AuditRepository` so actor profile audit writes no longer depend on `TaskRepository`. - Removed the redundant task-service worker-profile facade; the route calls `ActorService.activate_worker_profile` directly. - Added persisted-value overposting assertions for `POST /api/v1/workers/me/profile` and task claim so spoofed identity fields cannot write malicious registry rows. - Added migration assertions proving new actor registry tables exist and obsolete worker/reviewer profile tables are removed rather than kept as compatibility stores. @@ -105,6 +105,6 @@ Results: ## Remaining Risks -- Actor profile audit writes use the existing task-owned audit ledger helper in v0.1. This keeps one audit source of truth for now, but a future shared audit module should extract the code boundary before actor/reputation work grows. +- The audit event table still lives with the current task-domain models in v0.1. Actor services now use a shared audit repository boundary, but a future audit module should own the model when actor/reputation work grows. - Existing routes outside the chunk may continue using pure `get_current_actor` until deliberately migrated. This chunk adds registration side effects to `/auth/me`, worker profile setup, project routes, checker routes, and task routes touched here. - The next Terminal Benchmark live API drill still needs to run through real HTTP calls against this implementation after PR review. diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md index 50790d0..4d10cb3 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md @@ -41,7 +41,7 @@ The live Terminal Benchmark drill proved worker profile setup must be real. Doin - `active` is explicit workflow eligibility and still requires the matching current token role. - `disabled` blocks workflow eligibility and is preserved by observation refresh. - Route authorization still reads current `ActorContext.roles`, not database profile rows. -- Actor profile audit events use the existing Workstream `audit_events` ledger through `TaskRepository.add_audit_event`. +- Actor profile audit events use the existing Workstream `audit_events` ledger through a shared `AuditRepository`. ## Alternatives Rejected @@ -49,7 +49,7 @@ The live Terminal Benchmark drill proved worker profile setup must be real. Doin - Persisted profiles as route permissions: rejected because profile rows are workflow metadata/eligibility, not product authorization. - Automatic worker/reviewer eligibility from token observation: rejected because eligibility must come from explicit profile workflows. - Keeping old worker/reviewer compatibility stores: rejected because v0.1 is still under construction and should not preserve stale authority. -- A new audit table/module in this chunk: deferred because the current accepted boundary is one v0.1 audit ledger, with extraction documented for future actor/reputation expansion. +- A new audit table in this chunk: deferred because the current accepted boundary is one v0.1 audit ledger. A shared `AuditRepository` now keeps actor services from depending on task repository methods. ## Scope Control @@ -139,7 +139,7 @@ Reviewer run IDs: see `WS-POL-001-11-internal-review-evidence.md`. | QA/test | PASS AFTER FIXES | None | Added disabled-worker claim denial and deleted demo-route regression coverage. | | security/auth | PASS | None | Confirmed no valid security/auth findings on the final code SHA. | | product/ops | PASS AFTER FIXES | None | Fixed role wording so Identity Issuer does not read as product-role authority. | -| architecture | PASS WITH LOW RISKS | None | Confirmed auth/profile boundaries; future shared audit module noted as follow-up. | +| architecture | PASS WITH LOW RISKS | None | Confirmed auth/profile boundaries and v0.1 audit ledger coupling. | | CI integrity | PASS AFTER FIXES | None | Fixed the API contract E2E source-ref namespace and kept source-ref validation intact. | | docs | PASS AFTER FIXES | None | Tightened Identity Issuer versus Workstream-owned role wording across docs and scripts. | | reuse/dedup | PASS AFTER FIXES | None | Added full reviewer provenance and corrected route-registration scope in evidence. | @@ -147,11 +147,18 @@ Reviewer run IDs: see `WS-POL-001-11-internal-review-evidence.md`. ## External Review -External review has not run yet. CodeRabbit and GitHub checks should run after the PR is opened. +External review response: + +- `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-external-review-response.md` + +CodeRabbit findings were triaged separately from internal reviewer evidence. +Valid actor-registry findings were fixed. The requested legacy-profile backfill +was rejected because it contradicts the no-backward-compatibility chunk +decision. ## Remaining Risks -- Actor profile audit persistence currently imports the existing task audit repository. This keeps one v0.1 audit ledger but should be extracted to a shared audit module before actor/reputation work grows. +- The audit event table still lives with the current task-domain models in v0.1. Actor services use a shared audit repository boundary, but a future audit module should own the model when actor/reputation work grows. - Existing routes outside this chunk may continue using pure `get_current_actor` until deliberately migrated; this chunk deliberately registers actor context on `/auth/me`, worker profile setup, project routes, checker routes, and touched task routes. - The next Terminal Benchmark live API drill still needs to run against this implementation through real HTTP calls after merge. diff --git a/backend/app/modules/actors/service.py b/backend/app/modules/actors/service.py index 2e1c378..de9c420 100644 --- a/backend/app/modules/actors/service.py +++ b/backend/app/modules/actors/service.py @@ -11,6 +11,7 @@ from app.core.config import get_settings from app.core.permissions import require_any_role +from app.modules.audit.repository import AuditRepository from app.modules.actors.models import ( ACTOR_PROFILE_TYPES, GLOBAL_PROFILE_SCOPE_ID, @@ -21,8 +22,7 @@ from app.modules.actors.repository import ActorRepository from app.modules.actors.schemas import ActorProfileActivationRequest, ActorProfileResponse from app.modules.tasks.models import AuditEvent -from app.modules.tasks.repository import TaskRepository -from app.schemas.auth import ActorContext, sanitized_claim_snapshot +from app.schemas.auth import ActorContext, normalized_relationship_profile_claims, sanitized_claim_snapshot TOKEN_OBSERVED_PROFILE_TYPES = {"worker", "reviewer", "admin", "project_manager"} @@ -50,7 +50,7 @@ def __init__(self, session: AsyncSession) -> None: """ self._session = session self._repo = ActorRepository(session) - self._audit_repo = TaskRepository(session) + self._audit_repo = AuditRepository(session) async def register_actor(self, actor: ActorContext) -> ActorIdentity: """Create or refresh identity and observed profiles for a verified actor. @@ -305,12 +305,20 @@ async def _can_skip_registry_refresh( for profile_type in self._observed_profile_types(actor.roles) ] required_profiles.extend(self._trusted_relationship_profiles(actor)) + if not required_profiles: + return True + + existing_profiles = { + (profile.profile_type, profile.scope_type, profile.scope_id): profile + for profile in await self._repo.list_profiles(actor.actor_id) + } for required_profile in required_profiles: - profile = await self._repo.get_profile( - actor.actor_id, - required_profile["profile_type"], - required_profile["scope_type"], - required_profile["scope_id"], + profile = existing_profiles.get( + ( + required_profile["profile_type"], + required_profile["scope_type"], + required_profile["scope_id"], + ) ) if profile is None: return False @@ -417,28 +425,13 @@ def _trusted_relationship_profiles(self, actor: ActorContext) -> list[dict]: Returns: Sanitized project-owner relationship profile claims. """ - raw_profiles = actor.claim_snapshot.get("workstream_relationship_profiles", []) - if not isinstance(raw_profiles, list): - return [] profiles: list[dict] = [] - for raw_profile in raw_profiles: - if not isinstance(raw_profile, dict): - continue - if raw_profile.get("profile_type") != "project_owner": - continue - scope_type = raw_profile.get("scope_type") - scope_id = raw_profile.get("scope_id") - if not isinstance(scope_type, str) or not isinstance(scope_id, str): - continue - scope_type = scope_type.strip() - scope_id = scope_id.strip() - if not scope_type or not scope_id: - continue + for raw_profile in normalized_relationship_profile_claims(actor.claim_snapshot): profiles.append( { - "profile_type": "project_owner", - "scope_type": scope_type, - "scope_id": scope_id, + "profile_type": raw_profile["profile_type"], + "scope_type": raw_profile["scope_type"], + "scope_id": raw_profile["scope_id"], "profile_metadata": {"source": "trusted_relationship_claim"}, } ) diff --git a/backend/app/modules/audit/__init__.py b/backend/app/modules/audit/__init__.py new file mode 100644 index 0000000..5d574fb --- /dev/null +++ b/backend/app/modules/audit/__init__.py @@ -0,0 +1 @@ +"""Shared audit persistence module.""" diff --git a/backend/app/modules/audit/repository.py b/backend/app/modules/audit/repository.py new file mode 100644 index 0000000..a1d2e75 --- /dev/null +++ b/backend/app/modules/audit/repository.py @@ -0,0 +1,33 @@ +"""Database access methods for shared audit records.""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.tasks.models import AuditEvent + + +class AuditRepository: + """Wraps persistence for audit events independent of domain services.""" + + def __init__(self, session: AsyncSession) -> None: + """Create a repository bound to one database session. + + Args: + session: Async SQLAlchemy session for the current unit of work. + """ + self._session = session + + async def add_audit_event(self, event: AuditEvent) -> AuditEvent: + """Persist an audit event. + + Args: + event: Audit event model to persist. + + Returns: + Persisted audit event model. + """ + self._session.add(event) + await self._session.flush() + await self._session.refresh(event) + return event diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 0a9f952..792cd59 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -7,29 +7,19 @@ from pydantic import BaseModel, Field -def sanitized_claim_snapshot(claim_snapshot: dict[str, Any]) -> dict[str, Any]: - """Return the audit-safe claim snapshot allowed in Workstream storage. +def normalized_relationship_profile_claims(claim_snapshot: dict[str, Any]) -> list[dict[str, str]]: + """Return sanitized Workstream relationship profiles from trusted claims. Args: claim_snapshot: Trusted token claims produced by the auth verifier. Returns: - Claim snapshot with relationship claims reduced to scope identity only. + Sanitized relationship profile records. Unsupported or malformed claim + entries are dropped rather than stored. """ - sanitized: dict[str, Any] = {} - raw_roles = claim_snapshot.get("roles") - if isinstance(raw_roles, list | tuple): - roles = [role.strip() for role in raw_roles if isinstance(role, str) and role.strip()] - elif isinstance(raw_roles, str): - roles = [role.strip() for role in raw_roles.split(",") if role.strip()] - else: - roles = [] - if roles: - sanitized["roles"] = roles - relationship_profiles = claim_snapshot.get("workstream_relationship_profiles") if not isinstance(relationship_profiles, list): - return sanitized + return [] sanitized_relationships: list[dict[str, str]] = [] for raw_profile in relationship_profiles: @@ -52,6 +42,32 @@ def sanitized_claim_snapshot(claim_snapshot: dict[str, Any]) -> dict[str, Any]: "scope_id": scope_id, } ) + return sanitized_relationships + + +def sanitized_claim_snapshot(claim_snapshot: dict[str, Any]) -> dict[str, Any]: + """Return the audit-safe claim snapshot allowed in Workstream storage. + + Args: + claim_snapshot: Trusted token claims produced by the auth verifier. + + Returns: + Claim snapshot with relationship claims reduced to scope identity only. + """ + sanitized: dict[str, Any] = {} + raw_roles = claim_snapshot.get("roles") + if isinstance(raw_roles, list | tuple): + roles = [role.strip() for role in raw_roles if isinstance(role, str) and role.strip()] + elif isinstance(raw_roles, str): + roles = [role.strip() for role in raw_roles.split(",") if role.strip()] + else: + roles = [] + if roles: + sanitized["roles"] = roles + + sanitized_relationships = normalized_relationship_profile_claims(claim_snapshot) + if not sanitized_relationships: + return sanitized sanitized["workstream_relationship_profiles"] = sanitized_relationships return sanitized diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index 6b1f41f..5d17039 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -9,7 +9,7 @@ from alembic import command from alembic.config import Config from httpx import ASGITransport, AsyncClient -from sqlalchemy import select +from sqlalchemy import delete, select from app.adapters.auth.dev import actor_id_from_external_identity from app.core.config import get_settings @@ -216,10 +216,25 @@ async def test_auth_me_refreshes_stale_observed_profile_metadata( ActorProfile.profile_type == "reviewer", ) ) + events = ( + await session.execute( + select(AuditEvent).where( + AuditEvent.entity_type == "actor_profile", + AuditEvent.actor_id == actor_id("metadata-refresh-reviewer"), + AuditEvent.event_type == "actor_profile_observation_refreshed", + ) + ) + ).scalars().all() assert profile is not None assert profile.profile_metadata == {"source": "verified_token_role"} assert profile.updated_at > stale_time + assert len(events) == 1 + assert events[0].from_status == "observed" + assert events[0].to_status == "observed" + assert events[0].event_payload["profile_type"] == "reviewer" + assert events[0].event_payload["previous_profile_metadata"] == {"source": "stale"} + assert events[0].event_payload["profile_metadata"] == {"source": "verified_token_role"} async def test_auth_me_refreshes_identity_after_configured_interval( @@ -340,6 +355,13 @@ async def test_worker_profile_activation_is_explicit_and_audited( assert body["status"] == "active" assert body["skill_tags"] == ["stem", "finance"] assert body["email"] == "explicit-worker@example.test" + repeat_response = await actor_client.post( + "/api/v1/workers/me/profile", + headers=auth_headers(), + json={"skill_tags": ["stem", "finance"]}, + ) + assert repeat_response.status_code == 200, repeat_response.text + async with db_session.get_session_factory()() as session: events = ( await session.execute( @@ -350,7 +372,16 @@ async def test_worker_profile_activation_is_explicit_and_audited( ) ).scalars().all() - assert any(event.event_type == "actor_profile_activated" for event in events) + activation_events = [ + event for event in events if event.event_type == "actor_profile_activated" + ] + assert len(activation_events) == 1 + assert activation_events[0].from_status == "observed" + assert activation_events[0].to_status == "active" + assert activation_events[0].event_payload["profile_type"] == "worker" + assert activation_events[0].event_payload["scope_type"] == "global" + assert activation_events[0].event_payload["scope_id"] == "global" + assert activation_events[0].event_payload["skill_tags"] == ["stem", "finance"] async def test_observation_preserves_active_and_disabled_statuses( @@ -512,6 +543,164 @@ async def test_scoped_project_owner_profile_comes_from_trusted_relationship_clai assert "token" not in str(claim_snapshot).lower() +async def test_relationship_claims_ignore_malformed_entries_and_observe_multiple_scopes( + actor_database_env: str, +) -> None: + actor = actor_context( + subject="multi-project-owner", + roles=("project_manager",), + claim_snapshot={ + "roles": ["project_manager"], + "workstream_relationship_profiles": [ + { + "profile_type": "project_owner", + "scope_type": " project ", + "scope_id": " project-alpha ", + }, + { + "profile_type": "reviewer", + "scope_type": "project", + "scope_id": "project-ignored", + }, + { + "profile_type": "project_owner", + "scope_type": "project", + "scope_id": "", + }, + { + "profile_type": "project_owner", + "scope_type": "project", + "scope_id": "project-beta", + }, + {"profile_type": "project_owner", "scope_type": 7, "scope_id": "bad"}, + "not-a-profile", + ], + }, + ) + async with db_session.get_session_factory()() as session: + service = ActorService(session) + await service.register_actor(actor) + await service.register_actor(actor) + + async with db_session.get_session_factory()() as session: + identity = await session.scalar( + select(ActorIdentity).where(ActorIdentity.actor_id == actor.actor_id) + ) + profiles = ( + await session.execute( + select(ActorProfile).where( + ActorProfile.actor_id == actor.actor_id, + ActorProfile.profile_type == "project_owner", + ) + ) + ).scalars().all() + audit_events = ( + await session.execute( + select(AuditEvent).where( + AuditEvent.entity_type == "actor_profile", + AuditEvent.actor_id == actor.actor_id, + AuditEvent.event_type == "actor_profile_observed", + ) + ) + ).scalars().all() + + assert identity is not None + assert identity.last_claim_snapshot["workstream_relationship_profiles"] == [ + { + "profile_type": "project_owner", + "scope_type": "project", + "scope_id": "project-alpha", + }, + { + "profile_type": "project_owner", + "scope_type": "project", + "scope_id": "project-beta", + }, + ] + assert { + (profile.scope_type, profile.scope_id, profile.status) + for profile in profiles + } == { + ("project", "project-alpha", "observed"), + ("project", "project-beta", "observed"), + } + project_owner_events = [ + event + for event in audit_events + if event.event_payload["profile_type"] == "project_owner" + ] + assert len(project_owner_events) == 2 + assert { + (event.event_payload["scope_type"], event.event_payload["scope_id"]) + for event in project_owner_events + } == {("project", "project-alpha"), ("project", "project-beta")} + + +async def test_missing_relationship_profile_forces_registry_refresh( + actor_database_env: str, +) -> None: + actor = actor_context( + subject="missing-relationship-profile", + roles=("project_manager",), + claim_snapshot={ + "roles": ["project_manager"], + "workstream_relationship_profiles": [ + { + "profile_type": "project_owner", + "scope_type": "project", + "scope_id": "project-recreated", + } + ], + }, + ) + async with db_session.get_session_factory()() as session: + service = ActorService(session) + await service.register_actor(actor) + + async with db_session.get_session_factory()() as session: + await session.execute( + delete(ActorProfile).where( + ActorProfile.actor_id == actor.actor_id, + ActorProfile.profile_type == "project_owner", + ) + ) + await session.commit() + + async with db_session.get_session_factory()() as session: + service = ActorService(session) + await service.register_actor(actor) + + async with db_session.get_session_factory()() as session: + profiles = ( + await session.execute( + select(ActorProfile).where( + ActorProfile.actor_id == actor.actor_id, + ActorProfile.profile_type == "project_owner", + ) + ) + ).scalars().all() + audit_events = ( + await session.execute( + select(AuditEvent).where( + AuditEvent.entity_type == "actor_profile", + AuditEvent.actor_id == actor.actor_id, + AuditEvent.event_type == "actor_profile_observed", + ) + ) + ).scalars().all() + + assert len(profiles) == 1 + assert profiles[0].scope_type == "project" + assert profiles[0].scope_id == "project-recreated" + assert profiles[0].status == "observed" + project_owner_events = [ + event + for event in audit_events + if event.event_payload["profile_type"] == "project_owner" + ] + assert len(project_owner_events) == 2 + + async def test_active_profile_without_matching_token_role_cannot_use_worker_profile_api( actor_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, diff --git a/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md b/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md index 52def0d..e042fbd 100644 --- a/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md +++ b/docs/internal_reviews/2026-06-13_week1_week2_deterministic_hardening.md @@ -120,7 +120,7 @@ Passed: cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py ``` -Result: `API contract e2e passed` and `PASS API contract database invariants`. +Result: `API contract real API e2e passed` and `PASS API contract database invariants`. Passed: From c08c1e0ea6aff53987e1244b5ffb77b06e1612f3 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 7 Jul 2026 07:36:26 +0100 Subject: [PATCH 25/25] Update actor registry review evidence --- .agent-loop/LOOP_STATE.md | 8 ++++---- .../WS-POL-001-11-internal-review-evidence.md | 20 ++++++++++++++----- .../reviews/WS-POL-001-11-pr-trust-bundle.md | 17 +++++++++++----- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index d9c65ca..43ffdb4 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -7,12 +7,12 @@ - Active implementation chunk: `WS-POL-001-11` - Actor Identity And Profile Registry - Branch: `codex/ws-pol-001-11-actor-profile-registry-impl` - Status: `WS-POL-001-10` merged through PR #72. `WS-POL-001-11` - implementation is committed, deterministic verification passed, required - internal reviewer tracks completed with no blocking findings, and evidence / - trust bundle are prepared for PR review. + implementation and CodeRabbit follow-up fixes are committed, deterministic + verification passed, required internal reviewer tracks completed with no + blocking findings, and evidence / trust bundle are prepared for human review. - Last merged implementation SHA: `cc78f2a` - Last merge commit: `1bbde47` -- Current gate: open PR and wait for external review / human checkpoint +- Current gate: PR #74 external review addressed; await final CI and human merge decision - Next chunk: inactive until `WS-POL-001-11` is reviewed and merged ## Operating Rule diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md index 221d3c0..010e607 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md @@ -10,11 +10,11 @@ valid findings addressed: yes ## Reviewed Revision -Reviewed code SHA: f1847d59dd2eb19bca5160e0514a8f47d8bc494f +Reviewed code SHA: 07295316c5973ea544d766b291d790147bc38920 -Reviewed at: 2026-07-07T04:49:07Z +Reviewed at: 2026-07-07T06:30:00Z -Reviewer run IDs: senior-engineering-review-019f3aa7-a13d-7052-85dc-635cbfa7dadb, senior-engineering-final-019f3ad7-f6ea-7d63-a2ba-614a31be77e1, qa-test-review-019f3aa7-a9a4-7e80-9fb5-8b9961b0c913, qa-test-rerun-019f3ac2-c5d7-7e90-947f-85309bd89808, qa-test-final-019f3ad7-e4ed-78d2-a2f2-cccd1ed13172, security-auth-review-019f3aa7-b1b3-73f2-959e-9eaae25fabd3, security-auth-final-019f3ad7-c49d-7141-95f3-3e7e7e4321e3, security-auth-final-019f3ae4-00fc-7083-8b3f-09e6dfe9cdbc, product-ops-review-019f3aa7-bce6-77b1-9053-09926c54b4c9, product-ops-final-019f3ad7-d48d-7712-8fe6-ac16bc686a9b, product-ops-final-019f3ae3-f393-7e22-85be-f5ddea8bc26f, architecture-review-019f3ab2-f368-7f03-bff7-29999e5a076f, ci-integrity-review-019f3ab2-f9fd-7263-82a3-d6fdf9d3967b, ci-integrity-rerun-019f3ac2-d856-7c80-9bcf-63f5c5b71c2d, ci-integrity-final-019f3ad7-b07d-7b01-9d88-034546c16eb2, ci-integrity-final-019f3ae3-da6f-7522-a591-7aba0b86c168, docs-review-019f3ab3-0752-7391-adac-3a72c9af37ba, docs-rerun-019f3ac2-ce8e-7c12-9bbe-c3ffc54d1166, docs-final-019f3ad7-b67e-7fe3-9601-fb0f8e8557bf, docs-final-019f3ae3-ea83-7fc3-82c2-50ebbef57bb1, reuse-dedup-review-019f3ab3-12ad-7c92-86e7-eee531d82a6a, reuse-dedup-rerun-019f3ac2-e587-7d33-a978-57734c17d681, test-delta-review-019f3ab3-2436-7b82-9e17-7dd083ef1afa, test-delta-rerun-019f3ac2-fb49-7530-968f-50cd1b79041b +Reviewer run IDs: senior-engineering-review-019f3aa7-a13d-7052-85dc-635cbfa7dadb, senior-engineering-final-019f3ad7-f6ea-7d63-a2ba-614a31be77e1, senior-engineering-external-followup-019f3b41-4157-7651-a4f4-7882d0d27743, qa-test-review-019f3aa7-a9a4-7e80-9fb5-8b9961b0c913, qa-test-rerun-019f3ac2-c5d7-7e90-947f-85309bd89808, qa-test-final-019f3ad7-e4ed-78d2-a2f2-cccd1ed13172, qa-test-external-followup-019f3b41-4884-7582-bb51-431e9ba771cc, security-auth-review-019f3aa7-b1b3-73f2-959e-9eaae25fabd3, security-auth-final-019f3ad7-c49d-7141-95f3-3e7e7e4321e3, security-auth-final-019f3ae4-00fc-7083-8b3f-09e6dfe9cdbc, security-auth-external-followup-019f3b41-5050-7d03-b46b-1211feb41ab5, product-ops-review-019f3aa7-bce6-77b1-9053-09926c54b4c9, product-ops-final-019f3ad7-d48d-7712-8fe6-ac16bc686a9b, product-ops-final-019f3ae3-f393-7e22-85be-f5ddea8bc26f, product-ops-external-followup-019f3b41-575a-7150-b0e4-178bf73c413f, architecture-review-019f3ab2-f368-7f03-bff7-29999e5a076f, ci-integrity-review-019f3ab2-f9fd-7263-82a3-d6fdf9d3967b, ci-integrity-rerun-019f3ac2-d856-7c80-9bcf-63f5c5b71c2d, ci-integrity-final-019f3ad7-b07d-7b01-9d88-034546c16eb2, ci-integrity-final-019f3ae3-da6f-7522-a591-7aba0b86c168, ci-integrity-external-followup-019f3b41-7044-7971-8952-a3e914dd5f29, docs-review-019f3ab3-0752-7391-adac-3a72c9af37ba, docs-rerun-019f3ac2-ce8e-7c12-9bbe-c3ffc54d1166, docs-final-019f3ad7-b67e-7fe3-9601-fb0f8e8557bf, docs-final-019f3ae3-ea83-7fc3-82c2-50ebbef57bb1, docs-external-followup-019f3b41-60ff-7003-9180-c978466cfcba, reuse-dedup-review-019f3ab3-12ad-7c92-86e7-eee531d82a6a, reuse-dedup-rerun-019f3ac2-e587-7d33-a978-57734c17d681, test-delta-review-019f3ab3-2436-7b82-9e17-7dd083ef1afa, test-delta-rerun-019f3ac2-fb49-7530-968f-50cd1b79041b After the reviewed SHA, only evidence and status files changed. @@ -72,11 +72,15 @@ Scope: - Fixed the API contract E2E source snapshot ref from an unapproved opaque namespace to `inline:/guides/...`. - Reworded role documentation so v0.1 token roles are bootstrap request context and Workstream remains product-role authority. - Reworded API drill docstrings so signed-token roles are bootstrap role claims, not roles granted by Flow. +- Fixed CodeRabbit follow-up findings for actor registry hot-path profile reads, shared relationship-claim parsing, actor audit repository coupling, and exact API contract output wording. +- Added regression coverage for malformed relationship claims, multiple scoped project-owner observations, missing scoped-profile refresh invalidation, and exact actor-profile audit payloads. +- Recorded the CodeRabbit response separately from internal review evidence. ## Commands Run ```bash -cd backend && .venv/bin/python -m ruff check tests/test_auth.py tests/test_tasks.py +cd backend && .venv/bin/python -m ruff check app tests scripts +cd backend && .venv/bin/docstr-coverage --config .docstr.yaml cd backend && .venv/bin/python -m ruff check scripts/api_contract_e2e.py scripts/week2_api_e2e.py python3 scripts/check_stale_workstream_wording.py python3 scripts/check_markdown_links.py @@ -84,6 +88,7 @@ git diff --check origin/main...HEAD python3 scripts/test_agent_gates.py python3 scripts/check_internal_review_evidence.py cd backend && .venv/bin/python -m pytest tests/test_alembic.py tests/test_actors.py tests/test_auth.py -q +cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test WORKSTREAM_TEST_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python -m pytest -q cd backend && .venv/bin/python -m pytest tests/test_auth.py::test_no_local_login_password_or_session_routes -q cd backend && .venv/bin/python -m pytest tests/test_tasks.py::test_disabled_worker_profile_cannot_claim_ready_task tests/test_tasks.py::test_worker_without_profile_cannot_claim_ready_task -q cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test WORKSTREAM_TEST_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py @@ -92,12 +97,17 @@ cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream Results: - Ruff: passed. +- Docstring coverage: passed at 100.0%. - Stale wording scan: passed. -- Markdown link check: passed for 24 changed Markdown files. +- Markdown link check: passed for 25 changed Markdown files. - Diff whitespace check: passed. - Agent gate tests: 26 passed. - Internal review evidence gate: passed after this evidence update. +- Full backend pytest: 369 passed in 3116.81s. - Migration/actor/auth tests: 41 passed in 348.89s. +- Actor registry focused tests: 16 passed in 190.65s. +- Auth tests: 21 passed in 57.37s. +- Worker profile/task targeted tests: 7 passed in 113.12s. - Demo route regression: 1 passed in 14.77s. - Task eligibility regressions: 2 passed in 84.84s. - API contract real API E2E: passed on final patch state. diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md index 4d10cb3..0d477ed 100644 --- a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md @@ -83,7 +83,8 @@ No Workstream-owned login/signup/session/password behavior was added. No post-su ## Tests/Checks Run ```bash -cd backend && .venv/bin/python -m ruff check tests/test_auth.py tests/test_tasks.py +cd backend && .venv/bin/python -m ruff check app tests scripts +cd backend && .venv/bin/docstr-coverage --config .docstr.yaml cd backend && .venv/bin/python -m ruff check scripts/api_contract_e2e.py scripts/week2_api_e2e.py python3 scripts/check_stale_workstream_wording.py python3 scripts/check_markdown_links.py @@ -91,6 +92,7 @@ git diff --check origin/main...HEAD python3 scripts/test_agent_gates.py python3 scripts/check_internal_review_evidence.py cd backend && .venv/bin/python -m pytest tests/test_alembic.py tests/test_actors.py tests/test_auth.py -q +cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test WORKSTREAM_TEST_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python -m pytest -q cd backend && .venv/bin/python -m pytest tests/test_auth.py::test_no_local_login_password_or_session_routes -q cd backend && .venv/bin/python -m pytest tests/test_tasks.py::test_disabled_worker_profile_cannot_claim_ready_task tests/test_tasks.py::test_worker_without_profile_cannot_claim_ready_task -q cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test WORKSTREAM_TEST_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py @@ -99,12 +101,17 @@ cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream Result summary: - Ruff: passed. +- Docstring coverage: passed at 100.0%. - Stale wording scan: passed. -- Markdown link check: passed for 24 changed Markdown files. +- Markdown link check: passed for 25 changed Markdown files. - Diff whitespace check: passed. - Agent gate tests: 26 passed. - Internal review evidence gate: passed after evidence update. +- Full backend pytest: 369 passed in 3116.81s. - Migration/actor/auth tests: 41 passed in 348.89s. +- Actor registry focused tests: 16 passed in 190.65s. +- Auth tests: 21 passed in 57.37s. +- Worker profile/task targeted tests: 7 passed in 113.12s. - Demo route regression: 1 passed in 14.77s. - Task eligibility regressions: 2 passed in 84.84s. - API contract real API E2E: passed on final patch state. @@ -129,7 +136,7 @@ Internal review evidence: - `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md` -Reviewed code SHA: `f1847d59dd2eb19bca5160e0514a8f47d8bc494f` +Reviewed code SHA: `07295316c5973ea544d766b291d790147bc38920` Reviewer run IDs: see `WS-POL-001-11-internal-review-evidence.md`. @@ -164,8 +171,8 @@ decision. ## Follow-Up Work -- Open PR and wait for CodeRabbit/GitHub checks. -- Address external review in a separate external-review response file if needed. +- Wait for final GitHub checks on the pushed branch. +- Human reviews PR #74 and decides whether to merge. - After merge, rerun the Terminal Benchmark live API drill using the canonical worker profile endpoint. ## Human Review Focus