diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index b87e12b13..3c66ed616 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -1604,6 +1604,10 @@ "group": "shared", "target": "backend/scripts/coverage_policy.py" }, + { + "group": "shared", + "target": "backend/scripts/ensure_local_minio_bucket.py" + }, { "group": "shared", "target": "backend/scripts/external_api_drill.py" @@ -1620,10 +1624,18 @@ "group": "shared", "target": "backend/scripts/identifier_inventory.py" }, + { + "group": "shared", + "target": "backend/scripts/issue_local_flow_token.py" + }, { "group": "shared", "target": "backend/scripts/legacy_actor_classification.py" }, + { + "group": "shared", + "target": "backend/scripts/local_flow_tokens.py" + }, { "group": "shared", "target": "backend/scripts/merge_test_lane_evidence.py" @@ -1669,7 +1681,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "95fa9e2fa5e30557c54a51940b41141a4ebc67f083345ff66fc359d854525070", + "authority_digest": "b6f91808500d5b24ccc163305e2acd637289695ac47b45ff3a838af34b4a69ba", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.commitrail/changes/pilot-local-stack.md b/.commitrail/changes/pilot-local-stack.md new file mode 100644 index 000000000..b597fc03b --- /dev/null +++ b/.commitrail/changes/pilot-local-stack.md @@ -0,0 +1,129 @@ +# PILOT-01 — Isolated Local API And Worker Stack + +- Initiative: `None` +- Durable disposition: `Complete` +- Intended merge outcome: A developer can start one checkout-isolated API, prefork worker, scheduler, PostgreSQL, Redis and MinIO stack and use existing Flow-style local authentication and authorization operations. + +## Intent + +Provide the base runtime needed to exercise real pilot workflows on a developer +host. The stack must use the existing artifact, authentication, authorization +and durable-worker owners so local convenience cannot become a second product +path. + +## Current behavior + +`docker-compose.yml` provides PostgreSQL, Redis, MinIO and an optional API. Its +API port and MinIO port are fixed, artifact storage is disabled, local auth uses +one development identity, and there is no Compose worker or scheduler. The +canonical worker topology and recovery schedule already live in +`backend/app/workers/celery_app.py`; local Flow-HMAC signing, the trust-root CLI, +and authorized grant APIs already exist in the real API drills and backend. + +## Bounded change + +### Allowed + +- Configure `docker-compose.yml`, `docker/backend/Dockerfile.dev` and the root + `.env.example` for a project-scoped six-service local stack with configurable + published ports and non-root writable scratch, including existing non-root + host UID/GID reuse with an explicit UID-zero refusal. +- Add a local-only MinIO bucket readiness helper, extract the existing + Flow-HMAC test signer for reuse by its real API drill, and add a CLI wrapper + with focused tests; move that signer's unchanged UUID-generation + classification to its extracted module. +- Add `docs/engineering/local-pilot.md` for clean start, identities, existing authority bootstrap, project/guide setup, recovery, isolation and teardown; + keep authenticated curl headers out of process arguments and clean their + private temporary files on success, failure and handled signals. +- Reconcile only the Docker/local-storage quickstart in `README.md` with the + required ignored root environment and six-service stack. +- Reconcile only the affected local-runtime and pilot claims in `docs/roadmap_status.md`. +- Add the focused helper test to the existing shared-foundation CI catalogue; + do not change lane selection or gate behavior. +- Register the three executable local helpers in the exact behavior-ownership + partition and allow only that additive shared-target transition. +- Teach the stale-authorization documentation check to recognize the exact + Docker Compose `worker` service-token positions used by this runbook, with + positive command cases and negative human/identifier/argument controls. +- Add this standalone change record. + +### Not allowed + +- Database migrations, ORM models, backend routes, authority contracts, new authentication modes, direct database bootstrap writes or authorization bypasses. +- Runner, model-proxy, checker-sandbox, frontend, cloud, hosted or production bootstrap work. +- A fabricated approved guide, a complete contributor-journey claim, or changes to the existing solo-worker guide drill. +- Destructive or cross-project Docker cleanup; all commands remain scoped to an explicit Compose project. + +## Design and decisions + +Compose reuses the backend development image for API, worker and beat. The +worker selects the existing prefork lifecycle, and beat consumes the recovery +schedule already owned by the Celery application. API startup provisions and +verifies only the configured checkout-local MinIO bucket before migrations and +application startup; the application and worker still access bytes only through +the canonical `ArtifactStore`. + +Every published host port is interpolated from the ignored checkout-local +`.env`. Compose's project name scopes containers, the default network, retained +database/broker/object volumes and bounded scratch. Local tokens contain +identity facts and empty role claims; the existing irreversible trust-root +bootstrap and authorized grant APIs remain the only way to confer authority. + +## Acceptance criteria + +- [x] Compose declares API, prefork worker, beat, PostgreSQL, Redis and MinIO with API, database, broker and both MinIO ports configurable. +- [x] API, worker and beat share one `s3_compatible` MinIO configuration and project-scoped scratch; startup creates and verifies the private bucket. +- [x] The token CLI emits distinct, short-lived Flow-HMAC identities without authority claims. +- [x] The runbook uses the existing trust-root and grant operations, prepares a draft project for guide upload, and does not fabricate guide approval. +- [x] Two differently named stacks can run simultaneously; stopping one does not stop or erase the other. +- [x] Linux proof exercises live API, prefork worker, beat recovery, PostgreSQL, Redis and MinIO behavior. +- [x] Docker Desktop/macOS support is documented with explicit unverified runtime limits when no macOS host is available. +- [x] Existing non-root host UIDs are supported without permitting UID zero, + and authenticated runbook requests do not expose bearer values through curl + arguments, exported environment or shell tracing; the request helper works + in both Bash and the macOS-default zsh. + +## Risk and review routing + +- Risk class: `L1` +- Required reviewers: `architecture`, `security`, `product_ops`, `documentation`, `qa`, `test_delta`, `ci_integrity` +- Human review focus: Compose process topology, bucket startup/readiness, secret and authority boundaries, project isolation, retained-volume teardown semantics, and the limits of the live guide/macOS evidence. + CI integrity review must confirm the technical-service-token matcher retains + human/identifier/argument rejection and the ownership partition changes only + the three declared shared helpers. + +## Evidence + +| Claim | Command or proof | Result | Remaining uncertainty | +|---|---|---|---| +| Rendered isolation and topology | `docker compose --profile backend config`; Compose-resolved project name plus label-filtered resources | Six services, prefork worker, configurable loopback ports and four project-scoped volumes render; supported name/resource inspection commands execute successfully. | Compose rendering is Linux-host evidence only. | +| Local helper contracts | `uv run --project backend --locked --extra dev pytest -q backend/tests/test_local_pilot_scripts.py backend/tests/test_api_contract_e2e.py` | 19 tests pass, including the unchanged API-drill token consumers and a transient first-listener retry. | This focused test supplements the live MinIO/API proof. | +| CI catalogue custody | `uv run --project backend --locked --extra dev pytest -q backend/tests/test_ci_lane_catalogue.py` | 42 tests pass; the new module belongs to the shared-foundation partition. | Hosted lane execution remains external. | +| Repository gate custody | `python3 scripts/check_stale_authorization_docs.py`; `python3 -m unittest scripts.test_lightweight_agent_gates`; behavior-ownership validation and focused partition test | Technical Compose service tokens pass while comments, option/command arguments, identifiers and human authority prose remain rejected; the ownership partition adds exactly the three shared local helpers and rejects a fourth target. | Hosted gate replay remains external. | +| Identifier inventory | `uv run --project backend --locked --extra dev pytest -q backend/tests/test_identifier_inventory.py`; exact classification diff | The extracted signer retains the same owner, expression key and external-identity-fixture classification at its new module path; no stale or unowned generation site remains. | Classification movement does not change generated token claims. | +| Linux live stack | Pinned-source build, six-service `up --wait`, Celery inspect, public Flow identity/bootstrap/grant calls, real PDF upload, MinIO `HEAD`/`GET` and forced worker/beat restarts | All six services become healthy; the worker reports prefork concurrency two and `pong`; five distinct humans, all six fixed service actors, two projects and two scoped submitter grants use existing public operations; the 855-byte guide object survives restart with its digest unchanged; the same durable setup ID remains reserved when missing provider credentials stop compilation; beat recovers from `SIGKILL` with its retained schedule. | No provider credential was available, so a successful model-backed compilation is not claimed. Docker Engine Linux only; no macOS host is available. | +| Runtime image contract | Backend image build and no-dispatch runtime construction; missing-credential construction probe; fresh UID 501/GID 20 scratch-volume write; cached-image counterfactual plus candidate UID 1/GID 20 and UID-zero builds | The committed Agents SDK extra is installed; dummy-key construction succeeds without provider I/O; absent credentials fail closed; existing numeric groups and non-root UIDs, including an already assigned UID, own and write the copied-up scratch volume without broad permissions. The old `useradd` form rejects UID 1, the candidate accepts that non-root collision, and a UID-zero candidate build fails before account creation. | Actual provider dispatch remains outside this proof. | +| Authenticated runbook requests | Focused local-pilot tests execute the documented helper with a fake curl process on success, curl status 23 and parent `SIGTERM`; direct Bash and zsh success/nonzero replay | The temporary header is mode 600; its bearer value is absent from curl arguments, the exported environment and captured output; cleanup occurs in all three cases while curl statuses 23 and 143 are retained. Every authenticated runbook request uses the helper, and its locals are valid in both documented shells. | As with any local secret, `SIGKILL` cannot run shell cleanup; the ignored environment and short-lived tokens remain checkout-local controls. | +| Two-stack isolation | `ws-pilot-runtime-dev` and `ws-pilot-runtime-peer` with disjoint ports, label-scoped inspection, full-profile shutdown/restart and exact peer disposal | Both six-service stacks are healthy together. Stopping the primary leaves peer API, worker topology, PostgreSQL head, Redis marker and MinIO marker intact; restarting the primary restores its two projects, Redis marker and guide object; peer disposal leaves the primary healthy. | Single Linux Docker daemon, not two physical hosts. | + +## Review findings + +Full-stack lifecycle commands include the backend profile, and resource +inspection resolves the project name through Compose rather than an unexported +shell variable. The image installs the committed agent runtime and supports an +existing numeric host group. Worker and beat PID files are container-local, +while scratch artifacts and the beat schedule remain project-volume state. The +runbook provisions all six existing guide/artifact service identities and +secures ignored environment copies before secrets are added. Image account +creation now permits a colliding non-root host UID but refuses UID zero, and +authenticated curl examples use private, signal-cleaned header files rather +than bearer-valued arguments. + +## Reconciliation + +- Current-source reconciliation: Current `main` at `66a26d8d` includes merged + PRs #485, #486 and #487. Their evaluation-capacity, CLI guide-declaration + and payment-cleanup changes remain intact and do not replace the local stack + owners changed here. +- Next usable boundary: Use the stack for genuine guide setup/recovery and cross-project authorization proof, then complete the public pilot journey in its separately owned chunks. Runner and model-proxy services remain deferred to PILOT-04/PILOT-05. +- Remaining risks: A real Docker Desktop/macOS run and a provider-backed completed guide setup were not available on this Linux host and must not be inferred from Compose or Linux evidence. diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..8c64a65f8 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Copy this file to the ignored root .env and give every checkout a unique name +# and non-overlapping host ports. Replace every change-me value before startup. +COMPOSE_PROJECT_NAME=ws-pilot-local-change-me +LOCAL_UID=1000 +LOCAL_GID=1000 + +WORKSTREAM_API_HOST_PORT=18000 +WORKSTREAM_POSTGRES_HOST_PORT=15433 +WORKSTREAM_REDIS_HOST_PORT=16379 +WORKSTREAM_MINIO_API_HOST_PORT=19000 +WORKSTREAM_MINIO_CONSOLE_HOST_PORT=19001 + +WORKSTREAM_POSTGRES_PASSWORD=change-me-postgres-password +WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:change-me-postgres-password@postgres:5432/workstream +WORKSTREAM_MINIO_ROOT_USER=change-me-minio-user +WORKSTREAM_MINIO_ROOT_PASSWORD=change-me-minio-password +WORKSTREAM_ARTIFACT_S3_BUCKET=workstream-local + +WORKSTREAM_FLOW_AUTH_ISSUER=https://flow.local/pilot +WORKSTREAM_FLOW_AUTH_AUDIENCE=workstream-local-pilot +WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET=change-me-flow-hmac-secret +# Canonical Base64 encodings of independent 32-byte values. +WORKSTREAM_API_RATE_LIMIT_KEY_SECRET=change-me-base64-32-byte-secret +WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET=change-me-base64-32-byte-secret + +# A real provider key is required only for the genuine guide setup run. +OPENAI_API_KEY= +WORKSTREAM_PROJECT_AGENT_MODEL=gpt-5.6-terra +WORKSTREAM_CELERY_WORKER_CONCURRENCY=2 diff --git a/README.md b/README.md index 0a732c2cd..ba8adf496 100644 --- a/README.md +++ b/README.md @@ -385,30 +385,45 @@ the backend does not run a separate guide extractor. ### Docker Workflow (Recommended) Prerequisites are Git, Docker Engine, and Docker Compose v2. From the repository -root, build the native-architecture Linux image and start the API with healthy -Postgres and Redis dependencies: +root, create the ignored checkout-local configuration, give it a unique +`COMPOSE_PROJECT_NAME`, replace every `change-me` value and select unused host +ports. The [local pilot runbook](docs/engineering/local-pilot.md) documents +secret generation, identities, grants, isolation and teardown. ```bash -docker compose up --build --wait backend +cp .env.example .env +chmod 600 .env +``` + +The `chmod` command applies to Linux, macOS and Git Bash. PowerShell users must +restrict the copied file to their Windows user with an equivalent private NTFS +ACL before adding secrets. + +Then build the native-architecture Linux images and start the API, prefork +Celery process, scheduler, PostgreSQL, Redis and MinIO: + +```bash +docker compose --profile backend up --build --wait ``` Then verify the API with the command for your shell: ```bash # macOS, Linux, or Git Bash -curl --fail http://127.0.0.1:8000/api/v1/health +curl --fail http://127.0.0.1:/api/v1/health ``` ```powershell # PowerShell -Invoke-RestMethod http://127.0.0.1:8000/api/v1/health +Invoke-RestMethod http://127.0.0.1:/api/v1/health ``` -The expected response is `{"status":"ok"}`. The backend service applies -Alembic migrations before serving, binds the API only to host loopback, and -uses explicit local-only development auth and key material. Artifact storage is -disabled in this first-run profile; integration tests configure MinIO when they -exercise the S3-compatible path. +Replace the placeholder with the port selected in root `.env`. The expected +response is `{"status":"ok"}`. The backend applies Alembic migrations before +serving and all published services bind only to host loopback. API, Celery process and +beat use the same S3-compatible MinIO and local Flow-HMAC settings. Startup +creates and verifies the checkout-local bucket; authority still comes only from +the existing trust-root and grant operations. The pinned image supports Linux x86_64 and aarch64. On Docker Desktop, it runs inside the Docker VM. @@ -433,11 +448,20 @@ an x86_64 or aarch64 machine. Docker is still used for backing services. Confirm that `python3 --version` reports Python 3.11 or 3.12 before creating the environment. Install uv 0.12.3 and use the committed lockfile; an unconstrained pip install is not a supported setup path. +Create and secure the root Compose configuration before its first command, as +described in the [local pilot runbook](docs/engineering/local-pilot.md). Give it +a unique project name, replace every required secret and choose unused backing +service host ports. The separate `backend/.env` configures the native process. ```bash +cp .env.example .env +chmod 600 .env +# Edit root .env before continuing. docker compose up -d --wait postgres redis cd backend cp .env.example .env +chmod 600 .env +# Edit backend/.env before continuing. python3 --version uv --version uv sync --locked --extra dev --python python3 @@ -456,20 +480,28 @@ Verify the API from another terminal with: curl --fail http://127.0.0.1:8000/api/v1/health ``` -`backend/.env` is ignored. Its checked-in example contains only public, -local-development values; replace those values when specifically testing key -rotation, and never reuse them in a shared or hosted environment. +Both files are ignored. In `backend/.env`, set `WORKSTREAM_DATABASE_URL` to the +root file's PostgreSQL password and selected loopback host port, and set +`WORKSTREAM_CELERY_BROKER_URL` to its Redis loopback host port. When native +artifact storage is enabled, also select the MinIO profile and copy the root +file's bucket and credentials while using the selected MinIO API host port in +the loopback endpoint. These native process URLs use `localhost`; Compose +containers continue to use internal service names and ports. The `chmod` +commands are for POSIX shells; PowerShell users need equivalent private NTFS +ACLs. ### Native Unified Guide Inference Set `WORKSTREAM_PROJECT_AGENT_MODEL=gpt-5.6-terra` (or your chosen supported model) and `OPENAI_API_KEY` in ignored `backend/.env`. Runtime, provider/API protocol, model and instructions are separate settings in `backend/.env.example`. -Start backing services using the port settings in that same file, then install -the agent runtime and load the environment into API and Celery worker: +Prepare the ignored root `.env` as described in the Docker workflow and start +backing services using its port and credential settings. Put native backend and +provider settings in ignored `backend/.env`, then install the agent runtime and +load that file into API and Celery worker: ```bash -docker compose --env-file backend/.env up -d --wait postgres redis minio +docker compose up -d --wait postgres redis minio cd backend uv sync --locked --extra dev --extra agents uv run --env-file .env uvicorn app.main:app --reload @@ -494,8 +526,8 @@ and on-demand profiling procedure. Implemented instrumentation does not imply a collector or monitoring service is configured or deployed. ```bash -docker compose logs -f backend -docker compose down +docker compose --profile backend logs -f backend worker beat +docker compose --profile backend down ``` For the native workflow, stop Uvicorn with `Ctrl+C` before running @@ -538,18 +570,17 @@ later builds reuse Docker's cache. CI builds or restores this image once and shares it across the existing backend jobs. This does not change the hosted AWS S3 provider or delete existing artifact volumes. -If either default host port is already in use, set -`WORKSTREAM_POSTGRES_HOST_PORT` or `WORKSTREAM_REDIS_HOST_PORT` before running -Compose. Native-backend users must put the same selected ports in -`backend/.env`; the containerized backend uses the internal service ports. - -MinIO uses the compose-only static credentials and the private -`workstream-artifacts` bucket. The integration tests create that bucket -automatically. For local runtime use, create the private bucket with an S3 -client against `http://localhost:9000` after MinIO is healthy, using access key -`workstream-minio` and secret key `workstream-minio-secret-key`, before starting -Workstream. Configure the runtime with the exact -[artifact storage settings](docs/spec_artifact_storage_service.md#s3-compatible-adapter). +Set every published host port in the ignored root `.env`: API, PostgreSQL, +Redis, MinIO API and MinIO console. Native-backend users must put the same +selected backing-service ports in `backend/.env`; containerized services always +use the internal DNS names and ports. + +MinIO uses checkout-local static credentials and the private bucket selected in +root `.env`. API startup creates and verifies that bucket before migrations; +the application and Celery process continue to access objects only through the +canonical `ArtifactStore`. Configure a native runtime with the exact +[artifact storage settings](docs/spec_artifact_storage_service.md#s3-compatible-adapter) +and the same private bucket. The repository-managed MinIO port is bound to host loopback. A Workstream process running on a separate non-production container network may instead use an operator-controlled private MinIO endpoint; that remains development/test @@ -559,16 +590,17 @@ runtime-ineligible until live deployment proof is approved; startup fails with `artifact_provider_live_proof_required` before credential probing or provider I/O. -The default local development URL is: +The local development URL uses the password and PostgreSQL host port selected +in the ignored environment files: ```text -postgresql+asyncpg://workstream:workstream@localhost:5433/workstream +postgresql+asyncpg://workstream:@localhost:/workstream ``` Destructive real API drills use the separate local test database: ```text -postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test +postgresql+asyncpg://workstream:@localhost:/workstream_test ``` One project-guide compilation proposes sufficiency findings and separate diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 02377fd32..0603e4f04 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -5,7 +5,6 @@ import asyncio import base64 import hashlib -import hmac import json import os import socket @@ -18,6 +17,8 @@ from uuid import uuid4 import httpx + +from local_flow_tokens import issue_flow_token from alembic import command from alembic.config import Config from pydantic import SecretStr @@ -122,31 +123,6 @@ async def activate_guide_for_e2e( TEST_MINIO_SECRET_KEY = "workstream-minio-secret-key" -def base64url_json(payload: dict) -> str: - """Encode a JSON payload as an unpadded base64url segment. - - Args: - payload: JSON-serializable payload. - - Returns: - Encoded JWT segment. - """ - raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode() - return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() - - -def base64url_bytes(payload: bytes) -> str: - """Encode bytes as an unpadded base64url segment. - - Args: - payload: Raw bytes to encode. - - Returns: - Encoded JWT segment. - """ - return base64.urlsafe_b64encode(payload).rstrip(b"=").decode() - - def flow_settings(env: dict[str, str]) -> tuple[str, str, str]: """Resolve local Flow settings from the runtime environment. @@ -163,61 +139,6 @@ def flow_settings(env: dict[str, str]) -> tuple[str, str, str]: ) -def issue_flow_token( - subject: str, - roles: list[str], - *, - issuer: str, - audience: str, - secret: str, - issued_at: datetime | None = None, - expires_at: datetime | None = None, - not_before: datetime | None = None, - subject_kind: str = "human", -) -> str: - """Issue a local Flow-compatible signed token for one QA actor. - - Args: - subject: External Flow subject. - 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. - issued_at: Optional issued-at timestamp override. - expires_at: Optional expiration timestamp override. - not_before: Optional not-before timestamp override. - subject_kind: Canonical human or fixed-service token kind. - - Returns: - HMAC-signed bearer token consumed by ``FlowAuthVerifier``. - """ - now = issued_at or datetime.now(UTC) - header = base64url_json({"alg": "HS256", "typ": "JWT"}) - claims = { - "iss": issuer, - "aud": audience, - "sub": subject, - "jti": f"local-e2e-{uuid4()}", - "subject_kind": subject_kind, - "scope": "workstream:service" if subject_kind == "service" else "workstream:access", - "iat": int(now.timestamp()), - "nbf": int((not_before or (now - timedelta(seconds=5))).timestamp()), - "exp": int((expires_at or (now + timedelta(minutes=30))).timestamp()), - } - if subject_kind == "human": - claims.update( - { - "email": f"{subject}@flow.local", - "name": subject.replace("-", " ").title(), - "roles": roles, - } - ) - payload = base64url_json(claims) - signed_content = f"{header}.{payload}".encode() - signature = hmac.new(secret.encode(), signed_content, hashlib.sha256).digest() - return f"{header}.{payload}.{base64url_bytes(signature)}" - - def auth_headers(token: str) -> dict[str, str]: """Build a bearer authorization header. diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 70987f744..52276eec8 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -129,6 +129,13 @@ }) CON_02B_DELIVERY_TARGETS = frozenset({'backend/app/modules/outbox/delivery_repository.py', 'backend/app/modules/outbox/api.py', 'backend/app/modules/outbox/delivery.py', 'backend/app/modules/outbox/registry.py'}) CI_LANE_CATALOGUE_TARGETS = frozenset({"backend/scripts/test_lane_catalogue.py"}) +LOCAL_PILOT_HELPER_TARGETS = frozenset( + { + "backend/scripts/ensure_local_minio_bucket.py", + "backend/scripts/issue_local_flow_token.py", + "backend/scripts/local_flow_tokens.py", + } +) MODULE_PUBLIC_API_FOUNDATION_TARGETS = frozenset( { "backend/app/api/routes/artifact_submissions.py", @@ -751,6 +758,7 @@ def _validate_additive_partition_transition( | ARCH_03C4_TARGETS | ARCH_03B9_TARGETS | CI_LANE_CATALOGUE_TARGETS + | LOCAL_PILOT_HELPER_TARGETS | MODULE_BOUNDARY_FOUNDATION_TARGETS | MODULE_PUBLIC_API_FOUNDATION_TARGETS | POL_03A_CALLABLE_TARGETS diff --git a/backend/scripts/ensure_local_minio_bucket.py b/backend/scripts/ensure_local_minio_bucket.py new file mode 100644 index 000000000..4026f5afb --- /dev/null +++ b/backend/scripts/ensure_local_minio_bucket.py @@ -0,0 +1,87 @@ +"""Create and verify the checkout-local MinIO bucket before API startup.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import sys +from urllib.parse import urlsplit + +from aiobotocore.session import AioSession +from botocore.exceptions import ClientError + +from app.core.config import Settings, get_settings + + +def _validate_local_minio(settings: Settings) -> None: + """Reject every non-local or non-MinIO target before a provider mutation.""" + endpoint = urlsplit(settings.artifact_s3_endpoint_url or "") + if ( + settings.environment != "local" + or settings.artifact_store_backend != "s3_compatible" + or settings.artifact_s3_provider_profile != "minio" + or endpoint.scheme != "http" + or endpoint.hostname != "minio" + or endpoint.port != 9000 + or endpoint.path not in {"", "/"} + or endpoint.query + or endpoint.fragment + ): + raise RuntimeError("local MinIO bucket provisioning is not configured") + + +async def ensure_local_minio_bucket( + settings: Settings, + *, + session_factory: Callable[[], AioSession] = AioSession, +) -> str: + """Idempotently create and verify the configured local MinIO bucket.""" + _validate_local_minio(settings) + access_key = settings.artifact_s3_access_key_id + secret_key = settings.artifact_s3_secret_access_key + bucket = settings.artifact_s3_bucket + if access_key is None or secret_key is None or bucket is None: + raise RuntimeError("local MinIO bucket provisioning is incomplete") + + for attempt in range(1, 11): + session = session_factory() + session.set_credentials(access_key.get_secret_value(), secret_key.get_secret_value()) + try: + async with session.create_client( + "s3", + endpoint_url=settings.artifact_s3_endpoint_url, + region_name=settings.artifact_s3_region, + ) as client: + try: + await client.create_bucket(Bucket=bucket) + except ClientError as error: + code = str(error.response.get("Error", {}).get("Code", "")) + if code != "BucketAlreadyOwnedByYou": + raise + await client.head_bucket(Bucket=bucket) + break + except Exception: + if attempt == 10: + raise RuntimeError("local MinIO bucket provisioning failed") from None + await asyncio.sleep(1) + return bucket + + +def main() -> int: + """Provision the bucket without printing credentials or provider errors.""" + try: + bucket = asyncio.run(ensure_local_minio_bucket(get_settings())) + except Exception as error: + message = ( + str(error) + if isinstance(error, RuntimeError) + else "local MinIO provisioning failed" + ) + print(message, file=sys.stderr) + return 1 + print(f"local MinIO bucket ready: {bucket}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/identifier_generation_classifications.json b/backend/scripts/identifier_generation_classifications.json index 9a8e76fa6..25c0a9962 100644 --- a/backend/scripts/identifier_generation_classifications.json +++ b/backend/scripts/identifier_generation_classifications.json @@ -79,7 +79,7 @@ "scripts/admin_api_drill.py|AuthorityDrill.last_admin.revoke|Assign:5eb0e430a7bfa378|0": "uuid4:call:transport_token", "scripts/admin_api_drill.py|AuthorityDrill.last_admin.revoke|Assign:50c09de1b190b007|0": "uuid4:call:transport_token", "scripts/api_contract_e2e.py|flow_settings|Return:dc575af68f5f5f0c|0": "uuid4:call:ephemeral_secret", - "scripts/api_contract_e2e.py|issue_flow_token|Assign:dd98645354be1f57|0": "uuid4:call:external_identity_fixture", + "scripts/local_flow_tokens.py|issue_flow_token|Assign:dd98645354be1f57|0": "uuid4:call:external_identity_fixture", "scripts/api_contract_e2e.py|exercise_rate_control_contract|Assign:fed221fef92f603b|0": "uuid4:call:external_identity_fixture", "scripts/api_contract_e2e.py|request_json|Assign:9a076497f47031d6|0": "uuid4:call:transport_token", "scripts/api_contract_e2e.py|request_json|Assign:4250dc6f3375447a|0": "uuid4:call:transport_token", diff --git a/backend/scripts/issue_local_flow_token.py b/backend/scripts/issue_local_flow_token.py new file mode 100644 index 000000000..d89b6fc9e --- /dev/null +++ b/backend/scripts/issue_local_flow_token.py @@ -0,0 +1,56 @@ +"""Issue one short-lived local Flow-HMAC token without granting authority.""" + +from __future__ import annotations + +import argparse +from datetime import UTC, datetime, timedelta +import os +import sys + +if __package__: + from .local_flow_tokens import issue_flow_token +else: + from local_flow_tokens import issue_flow_token + + +def _required_environment(name: str) -> str: + value = os.environ.get(name, "") + if not value: + raise RuntimeError(f"{name} must be set") + return value + + +def issue_token(subject: str, *, lifetime_seconds: int) -> str: + """Use the existing local Flow signer with no authority-bearing role claims.""" + if not subject or subject != subject.strip() or len(subject.encode("utf-8")) > 500: + raise ValueError("subject must be a non-empty trimmed value of at most 500 bytes") + if not 60 <= lifetime_seconds <= 86_400: + raise ValueError("lifetime must be between 60 and 86400 seconds") + now = datetime.now(UTC) + return issue_flow_token( + subject, + [], + issuer=_required_environment("WORKSTREAM_FLOW_AUTH_ISSUER"), + audience=_required_environment("WORKSTREAM_FLOW_AUTH_AUDIENCE"), + secret=_required_environment("WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET"), + issued_at=now, + expires_at=now + timedelta(seconds=lifetime_seconds), + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--subject", required=True) + parser.add_argument("--lifetime-seconds", type=int, default=3_600) + args = parser.parse_args(argv) + try: + token = issue_token(args.subject, lifetime_seconds=args.lifetime_seconds) + except (RuntimeError, ValueError) as error: + print(str(error), file=sys.stderr) + return 2 + print(token) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/local_flow_tokens.py b/backend/scripts/local_flow_tokens.py new file mode 100644 index 000000000..8d917970c --- /dev/null +++ b/backend/scripts/local_flow_tokens.py @@ -0,0 +1,61 @@ +"""Shared local-only Flow-HMAC token signer for drills and developer tooling.""" + +from __future__ import annotations + +import base64 +from datetime import UTC, datetime, timedelta +import hashlib +import hmac +import json +from uuid import uuid4 + + +def base64url_json(payload: dict) -> str: + """Encode a JSON payload as an unpadded base64url segment.""" + raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode() + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def base64url_bytes(payload: bytes) -> str: + """Encode bytes as an unpadded base64url segment.""" + return base64.urlsafe_b64encode(payload).rstrip(b"=").decode() + + +def issue_flow_token( + subject: str, + roles: list[str], + *, + issuer: str, + audience: str, + secret: str, + issued_at: datetime | None = None, + expires_at: datetime | None = None, + not_before: datetime | None = None, + subject_kind: str = "human", +) -> str: + """Issue the HMAC token accepted only by Workstream's local Flow verifier.""" + now = issued_at or datetime.now(UTC) + header = base64url_json({"alg": "HS256", "typ": "JWT"}) + claims = { + "iss": issuer, + "aud": audience, + "sub": subject, + "jti": f"local-e2e-{uuid4()}", + "subject_kind": subject_kind, + "scope": "workstream:service" if subject_kind == "service" else "workstream:access", + "iat": int(now.timestamp()), + "nbf": int((not_before or (now - timedelta(seconds=5))).timestamp()), + "exp": int((expires_at or (now + timedelta(minutes=30))).timestamp()), + } + if subject_kind == "human": + claims.update( + { + "email": f"{subject}@flow.local", + "name": subject.replace("-", " ").title(), + "roles": roles, + } + ) + payload = base64url_json(claims) + signed_content = f"{header}.{payload}".encode() + signature = hmac.new(secret.encode(), signed_content, hashlib.sha256).digest() + return f"{header}.{payload}.{base64url_bytes(signature)}" diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index e4d60822f..39c2f14dc 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -59,6 +59,7 @@ class TestLane: "tests/test_project_agent_resilience.py", "tests/test_guide_document_api_contract.py", "tests/test_api_contract_e2e.py", + "tests/test_local_pilot_scripts.py", "tests/test_api_controls.py", "tests/test_identifiers.py", "tests/test_identifier_schema.py", diff --git a/backend/tests/test_behavior_ownership.py b/backend/tests/test_behavior_ownership.py index 9b880a8bb..e021bb5a2 100644 --- a/backend/tests/test_behavior_ownership.py +++ b/backend/tests/test_behavior_ownership.py @@ -325,6 +325,27 @@ def test_partition_allows_the_module_boundary_foundation_target( } +def test_partition_allows_only_the_exact_local_pilot_helpers() -> None: + """The local pilot adds three shared helper targets without wider custody.""" + existing = "backend/scripts/existing.py" + expected = { + "backend/scripts/ensure_local_minio_bucket.py", + "backend/scripts/issue_local_flow_token.py", + "backend/scripts/local_flow_tokens.py", + } + assert ownership.LOCAL_PILOT_HELPER_TARGETS == expected + trusted = _partition([existing]) + ownership._validate_additive_partition_transition( + _partition(sorted({existing, *expected})), + trusted, + ) + with pytest.raises(ownership.BehaviorOwnershipError, match="untrusted_partition_change"): + ownership._validate_additive_partition_transition( + _partition(sorted({existing, *expected, "backend/scripts/pilot_extra.py"})), + trusted, + ) + + @pytest.mark.parametrize("case", ("extra", "removal", "reassignment", "trusted_digest", "base")) def test_partition_additive_transition_rejects_custody_drift( tmp_path: Path, diff --git a/backend/tests/test_local_pilot_scripts.py b/backend/tests/test_local_pilot_scripts.py new file mode 100644 index 000000000..250a66751 --- /dev/null +++ b/backend/tests/test_local_pilot_scripts.py @@ -0,0 +1,253 @@ +"""Focused checks for local-pilot provisioning and token helpers.""" + +from __future__ import annotations + +from datetime import UTC, datetime +import importlib +import json +import os +from pathlib import Path +import re +import subprocess +from types import SimpleNamespace + +import jwt +import pytest + +from app.core.config import Settings + + +bucket_script = importlib.import_module("scripts.ensure_local_minio_bucket") +token_script = importlib.import_module("scripts.issue_local_flow_token") +LOCAL_PILOT_GUIDE = Path(__file__).parents[2] / "docs" / "engineering" / "local-pilot.md" + + +def _guide_shell_function(name: str) -> str: + guide = LOCAL_PILOT_GUIDE.read_text(encoding="utf-8") + match = re.search(rf"(?ms)^{re.escape(name)}\(\) \{{\n.*?^\}}$", guide) + assert match is not None + return match.group(0) + + +def _write_fake_curl(path: Path) -> None: + path.write_text( + """#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import signal +import sys +import time + +arguments = sys.argv[1:] +header_option = arguments.index("--header") +header_reference = arguments[header_option + 1] +assert header_reference.startswith("@") +header_path = Path(header_reference[1:]) +header = header_path.read_text(encoding="utf-8") +token = header.removeprefix("Authorization: Bearer ").strip() +report = { + "arguments": arguments, + "header": header, + "header_path": str(header_path), + "header_mode": oct(header_path.stat().st_mode & 0o777), + "token_in_arguments": any(token in argument for argument in arguments), + "token_in_environment": any(token in value for value in os.environ.values()), +} +Path(os.environ["FAKE_CURL_REPORT"]).write_text(json.dumps(report), encoding="utf-8") +if os.environ.get("FAKE_CURL_SIGNAL_PARENT") == "1": + os.kill(os.getppid(), signal.SIGTERM) + time.sleep(0.1) +sys.exit(int(os.environ["FAKE_CURL_EXIT"])) +""", + encoding="utf-8", + ) + path.chmod(0o755) + + +def _run_guide_curl_helper( + tmp_path: Path, *, exit_status: int, signal_parent: bool = False +) -> tuple[subprocess.CompletedProcess[str], dict[str, object], str]: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_fake_curl(fake_bin / "curl") + report_path = tmp_path / "curl-report.json" + token = "synthetic-local-pilot-bearer-token" + script = f"""{_guide_shell_function("curl_with_token")} +TOKEN='{token}' +curl_with_token "$TOKEN" --fail --silent https://workstream.invalid/example +exit $? +""" + environment = os.environ.copy() + environment.update( + { + "FAKE_CURL_EXIT": str(exit_status), + "FAKE_CURL_REPORT": str(report_path), + "PATH": f"{fake_bin}:{environment['PATH']}", + "TMPDIR": str(tmp_path), + } + ) + if signal_parent: + environment["FAKE_CURL_SIGNAL_PARENT"] = "1" + completed = subprocess.run( + ["bash"], + input=script, + text=True, + capture_output=True, + env=environment, + check=False, + ) + report = json.loads(report_path.read_text(encoding="utf-8")) + return completed, report, token + + +def minio_settings(**overrides: object) -> Settings: + values: dict[str, object] = { + "environment": "local", + "artifact_store_backend": "s3_compatible", + "artifact_s3_provider_profile": "minio", + "artifact_s3_region": "us-east-1", + "artifact_s3_endpoint_url": "http://minio:9000", + "artifact_s3_bucket": "workstream-local", + "artifact_s3_private_prefix": "artifacts", + "artifact_s3_addressing_style": "path", + "artifact_s3_credential_mode": "local_static", + "artifact_s3_access_key_id": "local-key", + "artifact_s3_secret_access_key": "local-secret", + "artifact_scratch_root": "/tmp/workstream-local-pilot-tests", + "artifact_admission_task_maximum_bytes": 67_108_864, + "artifact_admission_producer_maximum_bytes": 268_435_456, + "artifact_admission_project_maximum_bytes": 1_073_741_824, + "artifact_admission_deployment_maximum_bytes": 4_294_967_296, + } + values.update(overrides) + return Settings.model_validate(values) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("environment", "production"), + ("artifact_store_backend", "disabled"), + ("artifact_s3_endpoint_url", "http://127.0.0.1:9000"), + ("artifact_s3_endpoint_url", "https://minio:9000"), + ], +) +def test_bucket_helper_rejects_non_compose_or_disabled_targets(field: str, value: str) -> None: + settings = minio_settings().model_copy(update={field: value}) + with pytest.raises(RuntimeError, match="local MinIO bucket provisioning is not configured"): + bucket_script._validate_local_minio(settings) + + +async def test_bucket_helper_retries_transient_first_connection(monkeypatch) -> None: + attempts = SimpleNamespace(create=0, sleep=0) + + class Client: + async def create_bucket(self, **kwargs: str) -> None: + assert kwargs == {"Bucket": "workstream-local"} + attempts.create += 1 + if attempts.create == 1: + raise OSError("MinIO listener is not accepting requests yet") + + async def head_bucket(self, **kwargs: str) -> None: + assert kwargs == {"Bucket": "workstream-local"} + + class ClientContext: + async def __aenter__(self) -> Client: + return Client() + + async def __aexit__(self, *_args: object) -> None: + return None + + class Session: + def set_credentials(self, access_key: str, secret_key: str) -> None: + assert (access_key, secret_key) == ("local-key", "local-secret") + + def create_client(self, service: str, **kwargs: object) -> ClientContext: + assert service == "s3" + assert kwargs == { + "endpoint_url": "http://minio:9000", + "region_name": "us-east-1", + } + return ClientContext() + + async def no_wait(_seconds: float) -> None: + attempts.sleep += 1 + + monkeypatch.setattr(bucket_script.asyncio, "sleep", no_wait) + bucket = await bucket_script.ensure_local_minio_bucket( + minio_settings(), session_factory=Session + ) + + assert bucket == "workstream-local" + assert (attempts.create, attempts.sleep) == (2, 1) + + +def test_token_helper_issues_distinct_identity_without_authority_claims(monkeypatch) -> None: + environment = { + "WORKSTREAM_FLOW_AUTH_ISSUER": "https://flow.local/pilot", + "WORKSTREAM_FLOW_AUTH_AUDIENCE": "workstream-local-pilot", + "WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET": "local-test-secret-with-at-least-32-bytes", + } + for name, value in environment.items(): + monkeypatch.setenv(name, value) + + token = token_script.issue_token("pilot-contributor-one", lifetime_seconds=600) + claims = jwt.decode( + token, + environment["WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET"], + algorithms=["HS256"], + audience=environment["WORKSTREAM_FLOW_AUTH_AUDIENCE"], + issuer=environment["WORKSTREAM_FLOW_AUTH_ISSUER"], + ) + + assert claims["sub"] == "pilot-contributor-one" + assert claims["roles"] == [] + assert claims["scope"] == "workstream:access" + assert claims["subject_kind"] == "human" + assert claims["exp"] - claims["iat"] == 600 + assert claims["iat"] <= int(datetime.now(UTC).timestamp()) + + +def test_token_helper_requires_the_shared_local_verifier_secret(monkeypatch) -> None: + monkeypatch.setenv("WORKSTREAM_FLOW_AUTH_ISSUER", "https://flow.local/pilot") + monkeypatch.setenv("WORKSTREAM_FLOW_AUTH_AUDIENCE", "workstream-local-pilot") + monkeypatch.delenv("WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET", raising=False) + + with pytest.raises(RuntimeError, match="WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET must be set"): + token_script.issue_token("pilot-manager", lifetime_seconds=600) + + +@pytest.mark.parametrize("exit_status", [0, 23]) +def test_runbook_curl_helper_keeps_token_private_and_cleans_header( + tmp_path: Path, exit_status: int +) -> None: + completed, report, token = _run_guide_curl_helper(tmp_path, exit_status=exit_status) + + assert completed.returncode == exit_status + assert report["header"] == f"Authorization: Bearer {token}\n" + assert report["header_mode"] == "0o600" + assert report["token_in_arguments"] is False + assert report["token_in_environment"] is False + assert not Path(str(report["header_path"])).exists() + assert token not in completed.stdout + assert token not in completed.stderr + + +def test_runbook_curl_helper_cleans_header_when_shell_is_signalled(tmp_path: Path) -> None: + completed, report, token = _run_guide_curl_helper( + tmp_path, exit_status=143, signal_parent=True + ) + + assert completed.returncode == 143 + assert report["header"] == f"Authorization: Bearer {token}\n" + assert not Path(str(report["header_path"])).exists() + assert token not in completed.stdout + assert token not in completed.stderr + + +def test_runbook_routes_authorization_headers_through_private_helper() -> None: + guide = LOCAL_PILOT_GUIDE.read_text(encoding="utf-8") + + assert guide.count("Authorization: Bearer") == 1 + assert '-H "Authorization: Bearer' not in guide diff --git a/docker-compose.yml b/docker-compose.yml index 0969c1eec..7aa463c3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,39 +1,68 @@ +name: ${COMPOSE_PROJECT_NAME:?set COMPOSE_PROJECT_NAME in the checkout-local .env} + +x-backend-runtime: &backend-runtime + profiles: ["backend"] + build: + context: . + dockerfile: docker/backend/Dockerfile.dev + args: + LOCAL_UID: ${LOCAL_UID:-1000} + LOCAL_GID: ${LOCAL_GID:-1000} + image: ${COMPOSE_PROJECT_NAME}-backend:local + init: true + user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}" + environment: &backend-environment + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + WORKSTREAM_API_RATE_LIMIT_KEY_SECRET: ${WORKSTREAM_API_RATE_LIMIT_KEY_SECRET:?set WORKSTREAM_API_RATE_LIMIT_KEY_SECRET in .env} + WORKSTREAM_ARTIFACT_ADMISSION_DEPLOYMENT_MAXIMUM_BYTES: "4294967296" + WORKSTREAM_ARTIFACT_ADMISSION_PRODUCER_MAXIMUM_BYTES: "268435456" + WORKSTREAM_ARTIFACT_ADMISSION_PROJECT_MAXIMUM_BYTES: "1073741824" + WORKSTREAM_ARTIFACT_ADMISSION_TASK_MAXIMUM_BYTES: "67108864" + WORKSTREAM_ARTIFACT_S3_ACCESS_KEY_ID: ${WORKSTREAM_MINIO_ROOT_USER:?set WORKSTREAM_MINIO_ROOT_USER in .env} + WORKSTREAM_ARTIFACT_S3_ADDRESSING_STYLE: path + WORKSTREAM_ARTIFACT_S3_BUCKET: ${WORKSTREAM_ARTIFACT_S3_BUCKET:-workstream-local} + WORKSTREAM_ARTIFACT_S3_CREDENTIAL_MODE: local_static + WORKSTREAM_ARTIFACT_S3_ENDPOINT_URL: http://minio:9000 + WORKSTREAM_ARTIFACT_S3_PRIVATE_PREFIX: artifacts + WORKSTREAM_ARTIFACT_S3_PROVIDER_PROFILE: minio + WORKSTREAM_ARTIFACT_S3_REGION: us-east-1 + WORKSTREAM_ARTIFACT_S3_SECRET_ACCESS_KEY: ${WORKSTREAM_MINIO_ROOT_PASSWORD:?set WORKSTREAM_MINIO_ROOT_PASSWORD in .env} + WORKSTREAM_ARTIFACT_SCRATCH_ROOT: /workspace/scratch/artifacts + WORKSTREAM_ARTIFACT_STORE_BACKEND: s3_compatible + WORKSTREAM_AUTH_PROVIDER: flow + WORKSTREAM_CELERY_BROKER_URL: redis://redis:6379/0 + WORKSTREAM_DATABASE_URL: ${WORKSTREAM_DATABASE_URL:?set WORKSTREAM_DATABASE_URL in .env} + WORKSTREAM_DEBUG: "true" + WORKSTREAM_ENVIRONMENT: local + WORKSTREAM_FLOW_AUTH_AUDIENCE: ${WORKSTREAM_FLOW_AUTH_AUDIENCE:-workstream-local-pilot} + WORKSTREAM_FLOW_AUTH_ISSUER: ${WORKSTREAM_FLOW_AUTH_ISSUER:-https://flow.local/pilot} + WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET: ${WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET:?set WORKSTREAM_FLOW_AUTH_LOCAL_HMAC_SECRET in .env} + WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET: ${WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET:?set WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET in .env} + WORKSTREAM_PROJECT_AGENT_MODEL: ${WORKSTREAM_PROJECT_AGENT_MODEL:-gpt-5.6-terra} + volumes: + - ./backend:/workspace/backend + - workstream_scratch_data:/workspace/scratch + services: backend: - profiles: ["backend"] - build: - context: . - dockerfile: docker/backend/Dockerfile.dev - init: true - user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}" + <<: *backend-runtime command: - /bin/sh - -c - | set -eu + python scripts/ensure_local_minio_bucket.py python -m alembic upgrade head exec python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload - environment: - WORKSTREAM_API_RATE_LIMIT_KEY_SECRET: ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8= - WORKSTREAM_ARTIFACT_STORE_BACKEND: disabled - WORKSTREAM_AUTH_PROVIDER: dev - WORKSTREAM_CELERY_BROKER_URL: redis://redis:6379/0 - WORKSTREAM_DATABASE_URL: postgresql+asyncpg://workstream:workstream@postgres:5432/workstream - WORKSTREAM_DEBUG: "true" - WORKSTREAM_DEV_AUTH_ISSUER: https://workstream.local/development - WORKSTREAM_DEV_AUTH_SUBJECT: local-developer - WORKSTREAM_DEV_AUTH_TOKEN: workstream-local-dev-token - WORKSTREAM_ENVIRONMENT: local - WORKSTREAM_PAGINATION_CURSOR_HMAC_SECRET: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= ports: - - "127.0.0.1:8000:8000" - volumes: - - ./backend:/workspace/backend + - "127.0.0.1:${WORKSTREAM_API_HOST_PORT:-8000}:8000" depends_on: postgres: condition: service_healthy redis: condition: service_healthy + minio: + condition: service_healthy healthcheck: test: - CMD @@ -47,12 +76,70 @@ services: retries: 20 start_period: 15s + worker: + <<: *backend-runtime + command: + - python + - -m + - celery + - -A + - app.workers.celery_app + - worker + - --pool=prefork + - --concurrency=${WORKSTREAM_CELERY_WORKER_CONCURRENCY:-2} + - --hostname=worker@%h + - --pidfile=/tmp/celery-worker.pid + - --loglevel=INFO + depends_on: + backend: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + healthcheck: + test: + - CMD-SHELL + - test -s /tmp/celery-worker.pid && kill -0 "$$(cat /tmp/celery-worker.pid)" + interval: 10s + timeout: 8s + retries: 12 + start_period: 20s + + beat: + <<: *backend-runtime + command: + - python + - -m + - celery + - -A + - app.workers.celery_app + - beat + - --schedule=/workspace/scratch/celerybeat-schedule + - --pidfile=/tmp/celerybeat.pid + - --loglevel=INFO + depends_on: + backend: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + healthcheck: + test: + - CMD-SHELL + - test -s /tmp/celerybeat.pid && kill -0 "$$(cat /tmp/celerybeat.pid)" + interval: 10s + timeout: 5s + retries: 12 + start_period: 15s + postgres: image: public.ecr.aws/docker/library/postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 environment: POSTGRES_DB: workstream POSTGRES_USER: workstream - POSTGRES_PASSWORD: workstream + POSTGRES_PASSWORD: ${WORKSTREAM_POSTGRES_PASSWORD:?set WORKSTREAM_POSTGRES_PASSWORD in .env} ports: - "127.0.0.1:${WORKSTREAM_POSTGRES_HOST_PORT:-5433}:5432" healthcheck: @@ -63,6 +150,7 @@ services: volumes: - workstream_postgres_uuid7_data:/var/lib/postgresql/data - ./docker/postgres/init:/docker-entrypoint-initdb.d:ro + redis: image: redis:7@sha256:b2b95679e3b46fb51864949ed25ea976fc3a6bcc00a40a1bc00d568cb2822e50 ports: @@ -74,20 +162,22 @@ services: interval: 5s timeout: 5s retries: 10 + minio: - image: workstream-minio:source + image: ${COMPOSE_PROJECT_NAME}-minio:source pull_policy: build build: context: docker/minio dockerfile: Dockerfile - command: server /data --address :9000 + command: server /data --address :9000 --console-address :9001 environment: - MINIO_ROOT_USER: workstream-minio - MINIO_ROOT_PASSWORD: workstream-minio-secret-key + MINIO_ROOT_USER: ${WORKSTREAM_MINIO_ROOT_USER:?set WORKSTREAM_MINIO_ROOT_USER in .env} + MINIO_ROOT_PASSWORD: ${WORKSTREAM_MINIO_ROOT_PASSWORD:?set WORKSTREAM_MINIO_ROOT_PASSWORD in .env} ports: - - "127.0.0.1:9000:9000" + - "127.0.0.1:${WORKSTREAM_MINIO_API_HOST_PORT:-9000}:9000" + - "127.0.0.1:${WORKSTREAM_MINIO_CONSOLE_HOST_PORT:-9001}:9001" healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/ready"] interval: 5s timeout: 5s retries: 20 @@ -98,3 +188,4 @@ volumes: workstream_postgres_uuid7_data: workstream_redis_uuid7_data: workstream_minio_data: + workstream_scratch_data: diff --git a/docker/backend/Dockerfile.dev b/docker/backend/Dockerfile.dev index 239d7b0f0..6ae2ddf19 100644 --- a/docker/backend/Dockerfile.dev +++ b/docker/backend/Dockerfile.dev @@ -15,22 +15,34 @@ ENV PATH="/opt/venv/bin:$PATH" \ UV_PROJECT_ENVIRONMENT=/opt/venv \ UV_PYTHON_DOWNLOADS=never +ARG LOCAL_UID=1000 +ARG LOCAL_GID=1000 + # Docker Desktop ARM VMs can advertise OpenSSL acceleration capabilities that # still SIGILL when PyCA's bundled OpenSSL dispatches to them. The generic # implementation is slower but deterministic and sufficient for local dev. -RUN groupadd --gid 1000 workstream \ - && useradd --uid 1000 --gid workstream --create-home --shell /bin/bash workstream +RUN if ! getent group "${LOCAL_GID}" >/dev/null; then \ + groupadd --gid "${LOCAL_GID}" workstream; \ + fi \ + && if [ "${LOCAL_UID}" -eq 0 ]; then \ + echo "LOCAL_UID=0 would run workstream as root" >&2; \ + exit 1; \ + fi \ + && useradd --non-unique --uid "${LOCAL_UID}" --gid "${LOCAL_GID}" --create-home --shell /bin/bash workstream WORKDIR /workspace/backend +RUN mkdir --parents /workspace/scratch \ + && chown --recursive "${LOCAL_UID}:${LOCAL_GID}" /workspace/scratch + COPY backend/ ./ # Install the committed resolution into a virtual environment outside the # bind-mounted source tree using the committed dependency resolution. RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --extra dev \ - && chown -R workstream:workstream /workspace /opt/venv + uv sync --locked --extra agents --extra dev \ + && chown -R "${LOCAL_UID}:${LOCAL_GID}" /workspace /opt/venv USER workstream diff --git a/docs/engineering/local-pilot.md b/docs/engineering/local-pilot.md new file mode 100644 index 000000000..ffa560ae9 --- /dev/null +++ b/docs/engineering/local-pilot.md @@ -0,0 +1,364 @@ +# Local Pilot Stack + +This guide starts the Workstream API, a prefork Celery worker, Celery beat, +PostgreSQL, Redis and MinIO for local pilot work. It uses the existing +`ArtifactStore`, Flow-token verifier, trust-root bootstrap and grant APIs. It +does not add a Workstream login, approve a guide, or provide the runner/model +proxy and complete contributor journey. + +The tested host path is Docker Engine on Linux x86_64. Docker Desktop is the +supported macOS path because these images select the Docker VM's Linux +architecture. This change was not run on a macOS host, so file sharing, +Docker Desktop resource limits, sleep/resume and both Apple Silicon and Intel +startup remain unverified here. + +## Configure one checkout + +Install Docker Compose v2, `curl`, `jq` and OpenSSL. From the repository root: + +```bash +cp .env.example .env +chmod 600 .env +``` + +Those are POSIX-shell commands. In Windows PowerShell, use `Copy-Item` and a +private NTFS ACL for the current user instead of `chmod`. `.env` is ignored. +Edit it before startup: + +- give `COMPOSE_PROJECT_NAME` a unique lowercase name such as + `ws-pilot-alex-api`; +- select unused loopback ports for API, PostgreSQL, Redis, MinIO API and MinIO + console; +- set `LOCAL_UID` and `LOCAL_GID` to the host values from `id -u` and `id -g`; + `LOCAL_UID` must be nonzero, so a root-run shell must instead supply the + intended non-root developer UID; +- replace the PostgreSQL and MinIO values with checkout-local secrets; +- keep the password embedded in `WORKSTREAM_DATABASE_URL` identical to + `WORKSTREAM_POSTGRES_PASSWORD` (use a URL-safe value); +- generate independent 32-byte Base64 secrets with `openssl rand -base64 32` + for the API rate-limit and pagination keys; +- generate a separate local Flow HMAC secret with `openssl rand -hex 32`. + +Set `OPENAI_API_KEY` only when running genuine guide compilation. Keep the +real key in this ignored file. Without it, the six services and artifact +upload can run, but provider-backed guide setup cannot complete successfully. + +Compose rejects a missing project name or required secret instead of silently +sharing defaults. All published ports bind to `127.0.0.1`. Containers use the +internal names `postgres:5432`, `redis:6379` and `minio:9000`; host-port changes +do not alter service-to-service URLs. + +## Start and inspect the six services + +```bash +docker compose --profile backend up --build --wait +docker compose --profile backend ps +docker compose --profile backend exec -T worker celery -A app.workers.celery_app inspect ping +docker compose --profile backend exec -T backend python scripts/ensure_local_minio_bucket.py +``` + +`ps` must show `backend`, the Celery service, `beat`, `postgres`, `redis` and `minio` +healthy. The Celery command must report `pong`. The bucket command must report +the configured bucket as ready. API, Celery process and beat receive the same +`s3_compatible` MinIO configuration and share the Compose-project scratch +volume; only the API performs idempotent bucket provisioning before startup. + +Resolve the configured client endpoints without assuming default ports: + +```bash +API_URL="http://$(docker compose --profile backend port backend 8000)" +MINIO_URL="http://$(docker compose --profile backend port minio 9000)" +MINIO_CONSOLE_URL="http://$(docker compose --profile backend port minio 9001)" +curl --fail --silent --show-error "$API_URL/api/v1/health" +curl --fail --silent --show-error "$MINIO_URL/minio/health/ready" +``` + +Inspect `docker compose --profile backend logs backend worker beat` if startup fails. A disabled +artifact backend is intentionally incompatible with this stack's retained +MinIO settings. This probe must exit nonzero before constructing an adapter and +report that static artifact credentials require MinIO storage: + +```bash +docker compose --profile backend run --rm --no-deps -e WORKSTREAM_ARTIFACT_STORE_BACKEND=disabled worker \ + python -c \ + 'from app.adapters.artifacts import create_artifact_store_bootstrap; from app.core.config import get_settings; create_artifact_store_bootstrap(get_settings())' +``` + +## Create distinct identities and grant authority + +The helper signs short-lived tokens using the exact local Flow-HMAC settings +already consumed by `FlowAuthVerifier`. It always emits empty role claims; +tokens identify actors but do not grant authority. + +Keep shell tracing disabled while tokens are in scope. The request helper writes +each Authorization header to a mode-600 temporary file, gives `curl` only the +file path, removes the file after success or failure and returns `curl`'s exact +status. + +```bash +set +x +set -o pipefail + +curl_with_token() { + local token="$1" header_file curl_status + shift + header_file="$(mktemp "${TMPDIR:-/tmp}/workstream-auth.XXXXXXXX")" || return + trap 'rm -f "$header_file"' HUP INT TERM + chmod 600 "$header_file" || { + curl_status=$? + rm -f "$header_file" + trap - HUP INT TERM + return "$curl_status" + } + printf 'Authorization: Bearer %s\n' "$token" >"$header_file" || { + curl_status=$? + rm -f "$header_file" + trap - HUP INT TERM + return "$curl_status" + } + if curl --header "@$header_file" "$@"; then + curl_status=0 + else + curl_status=$? + fi + rm -f "$header_file" + trap - HUP INT TERM + return "$curl_status" +} + +ADMIN_TOKEN="$(docker compose --profile backend exec -T backend python scripts/issue_local_flow_token.py --subject pilot-access-admin)" +MANAGER_TOKEN="$(docker compose --profile backend exec -T backend python scripts/issue_local_flow_token.py --subject pilot-project-manager)" +CONTRIBUTOR_ONE_TOKEN="$(docker compose --profile backend exec -T backend python scripts/issue_local_flow_token.py --subject pilot-contributor-one)" +CONTRIBUTOR_TWO_TOKEN="$(docker compose --profile backend exec -T backend python scripts/issue_local_flow_token.py --subject pilot-contributor-two)" +FINANCE_TOKEN="$(docker compose --profile backend exec -T backend python scripts/issue_local_flow_token.py --subject pilot-finance)" +``` + +Create each actor profile through the public identity boundary and retain only +the returned IDs in the shell: + +```bash +actor_id() { + curl_with_token "$1" --fail --silent --show-error \ + "$API_URL/api/v1/actors/me" | jq -r .actor_profile_id +} +ADMIN_ID="$(actor_id "$ADMIN_TOKEN")" +MANAGER_ID="$(actor_id "$MANAGER_TOKEN")" +CONTRIBUTOR_ONE_ID="$(actor_id "$CONTRIBUTOR_ONE_TOKEN")" +CONTRIBUTOR_TWO_ID="$(actor_id "$CONTRIBUTOR_TWO_TOKEN")" +FINANCE_ID="$(actor_id "$FINANCE_TOKEN")" +``` + +The first Access Administrator transition is irreversible. Run it once on a +new database using the existing bootstrap operation: + +```bash +docker compose --profile backend exec -T backend python scripts/bootstrap_access_administrator.py \ + --actor-profile-id "$ADMIN_ID" --execute +``` + +Issue the Project Manager and Finance grants through the authorized API. The +token claims remain empty: + +```bash +new_key() { docker compose --profile backend exec -T backend python -c 'import uuid; print(uuid.uuid4())'; } +curl_with_token "$ADMIN_TOKEN" --fail --silent --show-error -X POST \ + -H "Content-Type: application/json" -H "Idempotency-Key: $(new_key)" \ + -d "{\"target_actor_profile_id\":\"$MANAGER_ID\",\"role\":\"project_manager\",\"scope_type\":\"system\",\"reason\":\"Local pilot project administration\"}" \ + "$API_URL/api/v1/admin-role-grants" | jq +curl_with_token "$ADMIN_TOKEN" --fail --silent --show-error -X POST \ + -H "Content-Type: application/json" -H "Idempotency-Key: $(new_key)" \ + -d "{\"target_actor_profile_id\":\"$FINANCE_ID\",\"role\":\"finance_authority\",\"scope_type\":\"system\",\"reason\":\"Local pilot finance administration\"}" \ + "$API_URL/api/v1/admin-role-grants" | jq +``` + +Provision the fixed service identities required by the existing guide artifact +and setup operations. This is also an authorized API operation: + +```bash +for identity in \ + artifact.put_resolver \ + artifact.verifier \ + artifact.scheduler \ + artifact.binding \ + artifact.guide_reader \ + project.setup; do + curl_with_token "$ADMIN_TOKEN" --fail --silent --show-error -X POST \ + -H "Content-Type: application/json" -H "Idempotency-Key: $(new_key)" \ + -d "{\"service_identity\":\"workstream.$identity\",\"subject\":\"local-pilot-$identity\",\"reason\":\"Local pilot durable guide setup\"}" \ + "$API_URL/api/v1/service-actors" | jq +done +``` + +## Create a draft project and scoped contributor grants + +Create two draft projects. The first is the guide-upload target; the second is +an authorization isolation control: + +```bash +PROJECT="$(curl_with_token "$MANAGER_TOKEN" --fail --silent --show-error -X POST \ + -H "Content-Type: application/json" -H "Idempotency-Key: $(new_key)" \ + -d '{"name":"Local pilot","slug":"local-pilot","description":"Checkout-local pilot evidence"}' \ + "$API_URL/api/v1/projects")" +PROJECT_ID="$(jq -r .id <<<"$PROJECT")" + +OTHER_PROJECT="$(curl_with_token "$MANAGER_TOKEN" --fail --silent --show-error -X POST \ + -H "Content-Type: application/json" -H "Idempotency-Key: $(new_key)" \ + -d '{"name":"Local pilot isolation","slug":"local-pilot-isolation","description":"Cross-project denial control"}' \ + "$API_URL/api/v1/projects")" +OTHER_PROJECT_ID="$(jq -r .id <<<"$OTHER_PROJECT")" +``` + +Grant each contributor the existing project-scoped `submitter` role. The +qualification snapshot records that this local seed has no external skill or +reputation records; it does not fabricate evidence: + +```bash +grant_submitter() { + curl_with_token "$MANAGER_TOKEN" --fail --silent --show-error -X POST \ + -H "Content-Type: application/json" -H "Idempotency-Key: $(new_key)" \ + -d "{\"target_actor_profile_id\":\"$1\",\"role\":\"submitter\",\"qualification\":{\"skills_snapshot\":{\"availability\":\"unavailable\",\"reference_ids\":[],\"unavailable_reason\":\"no_record\"},\"reputation_snapshot\":{\"availability\":\"unavailable\",\"reference_ids\":[],\"unavailable_reason\":\"no_record\"},\"prior_project_work_refs\":[],\"external_expertise_refs\":[]},\"reason\":\"Local pilot contributor\"}" \ + "$API_URL/api/v1/projects/$PROJECT_ID/role-grants" | jq +} +grant_submitter "$CONTRIBUTOR_ONE_ID" +grant_submitter "$CONTRIBUTOR_TWO_ID" +``` + +Both contributors can read the granted project and are concealed from the +ungranted project: + +```bash +curl_with_token "$CONTRIBUTOR_ONE_TOKEN" --fail --silent --show-error \ + "$API_URL/api/v1/projects/$PROJECT_ID" | jq -e ".id == \"$PROJECT_ID\"" +test "$(curl_with_token "$CONTRIBUTOR_ONE_TOKEN" --silent --output /dev/null --write-out '%{http_code}' \ + "$API_URL/api/v1/projects/$OTHER_PROJECT_ID")" = 404 +``` + +Task release denial needs a real task under an activated guide. This base stack +does not fabricate an approved guide or task merely to satisfy that check; run +that negative case when the genuine guide below has passed the existing +approval/activation operations. + +## Declare and upload a real guide document + +Set `GUIDE_PDF` to a real bounded PDF. The document stays a draft source; this +flow does not approve or activate it. + +```bash +GUIDE_PDF=/absolute/path/to/project-guide.pdf +test -f "$GUIDE_PDF" +GUIDE="$(curl_with_token "$MANAGER_TOKEN" --fail --silent --show-error -X POST \ + -H "Content-Type: application/json" -H "Idempotency-Key: $(new_key)" \ + -d "{\"version\":\"initial\",\"change_summary\":\"Initial local pilot source\",\"task_examples\":[{\"title\":\"Pilot contribution\",\"labels\":[\"pilot\"],\"content\":\"Complete the assigned pilot work and return the required evidence.\"}],\"documents\":[{\"label\":\"$(basename "$GUIDE_PDF")\",\"media_type\":\"application/pdf\"}]}" \ + "$API_URL/api/v1/projects/$PROJECT_ID/guides")" +GUIDE_ID="$(jq -r .id <<<"$GUIDE")" +DOCUMENT_ID="$(jq -r '.documents[0].document_id' <<<"$GUIDE")" + +curl_with_token "$MANAGER_TOKEN" --fail --silent --show-error -X POST \ + -H "Content-Type: application/pdf" -H "Idempotency-Key: $(new_key)" \ + --data-binary "@$GUIDE_PDF" \ + "$API_URL/api/v1/projects/$PROJECT_ID/guides/$GUIDE_ID/documents/$DOCUMENT_ID/content" | jq +``` + +The upload is complete only when the response and Celery logs show the stored +object was verified through MinIO. Inspect the durable setup state rather than +assuming checker/provider success: + +```bash +curl_with_token "$MANAGER_TOKEN" --fail --silent --show-error \ + "$API_URL/api/v1/projects/$PROJECT_ID/guides/$GUIDE_ID/setup-runs/latest" | jq +docker compose --profile backend logs --since 10m worker beat +``` + +With a valid `OPENAI_API_KEY`, poll that endpoint until `finished_at` is set and +confirm `output_sufficiency_report_id` is non-null. A failure or unresolved +provider outcome is evidence to diagnose, not acceptance. Restart the Celery process +during a pending run and observe the same setup ID afterward to exercise the +existing recovery scans: + +Without a provider key, the real Celery process records the durable setup and +compilation reservation, then stops before dispatch. The latest setup can +therefore remain `compilation_reserved` with no sufficiency report; preserving +the same setup ID across restart proves recovery identity, not successful model +compilation. + +```bash +docker compose --profile backend kill -s KILL worker +docker compose --profile backend up -d --wait worker +curl_with_token "$MANAGER_TOKEN" --fail --silent --show-error \ + "$API_URL/api/v1/projects/$PROJECT_ID/guides/$GUIDE_ID/setup-runs/latest" | jq +``` + +Exercise scheduler recovery separately. A forced beat restart must become +healthy again and continue using the same project-scoped schedule volume. The +schedule is durable, while the process PID file is container-local so a killed +container cannot strand a reusable PID in the volume: + +```bash +docker compose --profile backend kill -s KILL beat +docker compose --profile backend up -d --wait beat +docker compose --profile backend ps beat +docker compose --profile backend logs --since 2m beat +``` + +## Prove two stacks do not interfere + +Use two checkouts, each with a different `.env` project name, five different +host ports and its own secrets. In each checkout run: + +```bash +docker compose --profile backend up --build --wait +docker compose --profile backend ps +``` + +Create a different project in each stack, then record the project IDs and +container/volume names: + +```bash +PROJECT_NAME="$(docker compose --profile backend config --format json | jq -er '.name')" +docker compose --profile backend ps --format '{{.Name}}' +docker volume ls --filter "label=com.docker.compose.project=$PROJECT_NAME" --format '{{.Name}}' +``` + +From the first checkout, stop only its project without deleting volumes: + +```bash +docker compose --profile backend down +``` + +In the second checkout, all six services must remain healthy, its project must +still be readable, and its bucket must still be present: + +```bash +docker compose --profile backend ps +curl --fail --silent --show-error "$API_URL/api/v1/health" +docker compose --profile backend exec -T backend python scripts/ensure_local_minio_bucket.py +``` + +Never use `docker compose --profile backend down` without first confirming the checkout's +`COMPOSE_PROJECT_NAME`. Do not use `docker system prune`, `docker volume prune`, +or remove another checkout's containers or volumes. + +## Stop, retain or dispose + +Normal shutdown retains PostgreSQL, Redis, MinIO and scratch data for the exact +Compose project: + +```bash +docker compose --profile backend down +``` + +`docker compose --profile backend up --wait` later reuses those named volumes. To delete all data +owned by this exact local project, first print and verify the project name and +resources, then remove its containers and named volumes: + +```bash +PROJECT_NAME="$(docker compose --profile backend config --format json | jq -er '.name')" +printf 'Compose project: %s\n' "$PROJECT_NAME" +docker compose --profile backend ps -a +docker volume ls --filter "label=com.docker.compose.project=$PROJECT_NAME" --format '{{.Name}}' +docker compose --profile backend down --volumes +``` + +That final command permanently removes this project's database, broker state, +MinIO objects and processing scratch. It does not remove other Compose projects +when the project name is unique. diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 9f42d2415..f9df5ad84 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -222,7 +222,7 @@ cannot be reused as post-submission review-gate evidence. See the | Contribution and compensation truth | **Hidden shared participant plus public policy administration** | ContributionPolicyVersion persistence; lifecycle-audit participant; adapter bindings; public Finance policy administration; REV-04B shared acceptance source storage; CON-03C immutable ContributionRecord/CompensationAward storage; obsolete guide-keyed payment storage and task-local payment fields removed; CON-07 source-neutral submitter participant and complete award sets; REV-04C composes it with FinalAcceptance/TASK effects in the caller transaction | Add authority/evidence and complete-set database closure before production consumption. Only actual Reviews create reviewer records. Add audit/outbox and fulfillment-root/ordinal custody before activation; no public recognition or fulfillment route is live | | Fulfillment, reconciliation, and audit | **Planned** | Shared audit foundations, provider-neutral adapter convention, AUTH-OUTBOX-02 live dispatcher authority, retained phase audit decisions, Celery delivery/recovery scans and CON-02B custody | Feature-specific handlers and authority, conditional award fulfillment, callbacks, idempotent recovery, reconciliation, bounded operational reads, and release controls | | Runtime diagnostics | **Implemented foundation** | Closed structured logging, explicit API/Celery tracing, bounded metrics, safe correlation and optional typed OTLP export | Restrict broker publishers; configure and secure collector/log access, egress, encryption and finite deletion; prove diagnostics during the release drill; no deployed monitoring claim | -| Frontend and pilot | **Planned after stable backend contracts** | React + Vite + TypeScript stack decision | Implement only stable backed surfaces, run the real internal pilot, repair findings, and complete release drills | +| Frontend and pilot | **Local runtime foundation; product pilot planned** | Checkout-isolated Docker Compose API, prefork Celery process, beat, PostgreSQL, Redis and MinIO stack with local Flow-HMAC identities, existing authority bootstrap/grants, configurable loopback ports and project-scoped state; React + Vite + TypeScript stack decision | Complete provider-backed guide compilation and activated-guide scoped task-denial proof on the base stack, implement only stable backed frontend surfaces, run the real internal pilot, repair findings, and complete release drills; Docker Desktop/macOS runtime proof remains | ## What Has Been Completed @@ -241,6 +241,12 @@ cannot be reused as post-submission review-gate evidence. See the natural keys and external request tokens retain their semantics. Old stamped development databases require explicitly scoped recreation, not conversion or compatibility stamping. Local Compose uses a separate UUIDv7 database volume. +- The [local pilot stack](engineering/local-pilot.md) runs API, the existing + prefork Celery topology, its recovery scheduler, PostgreSQL, Redis and MinIO + under one explicit Compose project. Every published port is checkout-local; + containers, network, retained data and bounded scratch are project-scoped. + Its Flow-HMAC helper creates identity only, while the existing trust-root and + grant operations remain the sole local authority path. - AWS S3 is the hosted artifact target, MinIO proves the storage protocol in development and CI, and all storage access stays behind `ArtifactStore`. Development/CI MinIO uses a shared pinned-source build; existing artifact @@ -626,9 +632,10 @@ The broader v0.1 sequence below retains later scope: rebase a continuing TaskAssignment only at the controlled human-revision boundary when the complete governed context changed. Finish recovery, fulfillment, reconciliation, audit, release controls, and legacy cleanup. -7. **Release proof.** Expose stable APIs and frontend surfaces, exercise the - complete path through real database, durable-job, storage, security, failure, - and recovery tests, then run the internal pilot. +7. **Release proof.** The isolated six-service local stack is available; use it + to expose stable APIs and frontend surfaces, exercise the complete path + through real database, durable-job, storage, security, failure, and recovery + tests, then run the internal pilot. Compose health alone is not journey proof. ## Engineering Quality Alongside Product Work @@ -810,6 +817,11 @@ The [observability foundation record](../.commitrail/changes/observability-found and [operator guide](engineering/observability.md) define diagnostic scope and remaining deployment responsibilities. +The [local pilot stack record](../.commitrail/changes/pilot-local-stack.md) and +[runbook](engineering/local-pilot.md) define the checkout-isolated runtime, +authority bootstrap, retained-data boundary and remaining platform/journey +proof. + Internal chunk identifiers are useful for implementation traceability, but a reader does not need internal engineering records to understand the roadmap above. The main diff --git a/scripts/check_stale_authorization_docs.py b/scripts/check_stale_authorization_docs.py index 0fb635292..55830c539 100644 --- a/scripts/check_stale_authorization_docs.py +++ b/scripts/check_stale_authorization_docs.py @@ -386,6 +386,11 @@ def technical_worker_match(text: str, match: re.Match[str]) -> bool: and re.search(r"(?:^|\n)[^;\n]*\bcelery\s+-A\s+app\.$", prefix) and re.match(r"\s+worker\s+--", after_code_reference) ) + exact_celery_app_reference = bool( + exact_code_path + and re.search(r"(?:^|\n)[^;\n]*\bcelery\s+-A\s+app\.$", prefix) + and suffix.startswith(".") + ) code_path_has_prose = bool( exact_code_path and not exact_coverage_path @@ -409,11 +414,28 @@ def technical_worker_match(text: str, match: re.Match[str]) -> bool: ) and suffix.startswith(" --") ) + current_line_prefix = prefix.rsplit("\n", 1)[-1] + compose_service_prefixes = ( + r"(?:|[^`\n]*`)docker compose --profile backend exec -T ", + r"(?:|[^`\n]*`)docker compose --profile backend logs(?: -f| --since \S+)*(?: backend)? ", + r"(?:|[^`\n]*`)docker compose --profile backend run --rm --no-deps -e \S+ ", + r"(?:|[^`\n]*`)docker compose --profile backend kill -s KILL ", + r"(?:|[^`\n]*`)docker compose --profile backend up -d --wait ", + ) + exact_compose_service = bool( + token.group(0).lower() == "worker" + and any( + re.fullmatch(pattern, current_line_prefix, re.IGNORECASE) + for pattern in compose_service_prefixes + ) + ) return bool( TECHNICAL_WORKER_PREFIX.search(prefix) or (exact_code_path and not code_path_has_prose) or exact_coverage_module + or exact_celery_app_reference or exact_celery_cli + or exact_compose_service or exact_technical_cli_flag ) diff --git a/scripts/test_lightweight_agent_gates.py b/scripts/test_lightweight_agent_gates.py index 40064896b..493abbfe5 100644 --- a/scripts/test_lightweight_agent_gates.py +++ b/scripts/test_lightweight_agent_gates.py @@ -51,6 +51,44 @@ def test_stale_authorization_rejects_noncanonical_api_prefix(self) -> None: failures = scan_authorization_text("docs/new-guide.md", "Call /v1/tasks.") self.assertIn("docs/new-guide.md:1: NON_CANONICAL_API_PREFIX", failures) + def test_stale_authorization_allows_exact_compose_worker_service(self) -> None: + commands = ( + "docker compose --profile backend exec -T worker " + "celery -A app.workers.celery_app inspect ping", + "docker compose --profile backend logs -f backend worker beat", + "Inspect `docker compose --profile backend logs --since 10m worker beat`.", + "docker compose --profile backend run --rm --no-deps " + "-e WORKSTREAM_ARTIFACT_STORE_BACKEND=disabled worker python -c 'pass'", + "docker compose --profile backend kill -s KILL worker", + "docker compose --profile backend up -d --wait worker", + ) + for command in commands: + with self.subTest(command=command): + self.assertEqual(scan_authorization_text("docs/new-guide.md", command), []) + + def test_stale_authorization_rejects_human_worker_vocabulary(self) -> None: + examples = ( + "The worker can approve project work.", + "# docker compose --profile backend exec -T worker", + "docker compose --profile backend exec -T backend echo worker", + "docker compose --profile backend logs --since worker", + "Use docker compose when the worker approves.", + "docker compose --profile backend exec -T worker_id echo ok", + ) + for example in examples: + with self.subTest(example=example): + failures = scan_authorization_text("docs/new-guide.md", example) + self.assertTrue( + any( + code in failure + for failure in failures + for code in ( + "HUMAN_WORKER_VOCABULARY", + "HUMAN_WORKER_IDENTIFIER", + ) + ) + ) + def test_stale_artifact_rejects_reached_phase_term(self) -> None: failures = scan_artifact_text( "README.md", "Use S3" + "ArtifactStore.", "artifact_store_cutover"