diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 3be91ac..593dff7 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -4,18 +4,16 @@ - Active initiative: `WS-POL-001` - Submission Artifact Policy Foundation - Active planning chunk: none -- Active implementation chunk: `WS-POL-001-12` -- Branch: `codex/ws-pol-001-12-project-setup-policy-visibility` -- Status: `WS-POL-001-12` is implemented in PR #76 on branch - `codex/ws-pol-001-12-project-setup-policy-visibility`. Internal review is - complete, CodeRabbit feedback is being addressed in a follow-up commit, and - the chunk is waiting for human merge review after external-review fixes are - verified. -- Last merged implementation SHA: `0729531` -- Last merge commit: `5cec0e0` -- Current gate: verify CodeRabbit fixes on PR #76, update trust evidence, then - wait for explicit human merge approval. -- Next chunk: inactive until `WS-POL-001-12` receives human review and merge. +- Active implementation chunk: `WS-POL-001-13` +- Branch: `codex/ws-pol-001-13-task-context-apis` +- Status: `WS-POL-001-13` is implementing worker-safe task work context, + exact submission requirements, and operator-only locked task provenance APIs + after PR #76 merged to `main`. +- Last merged implementation SHA: `46e74de` +- Last merge commit: `46e74de` +- Current gate: implement Chunk 13, run deterministic proof, run required + internal reviewer tracks, then open a PR for human review. +- Next chunk: inactive until `WS-POL-001-13` receives human review and merge. ## Operating Rule @@ -115,3 +113,7 @@ blockchain, frontend, or agent-runtime behavior. actors before the next Terminal Benchmark live API drill. - `WS-POL-001-11` internal review evidence is tracked at `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-internal-review-evidence.md`. - `WS-POL-001-11` PR trust bundle is tracked at `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-11-pr-trust-bundle.md`. +- PR #76 merged into `main` as `46e74de`; it implemented `WS-POL-001-12` + project setup-run and policy visibility APIs. +- `WS-POL-001-13` started on branch `codex/ws-pol-001-13-task-context-apis` + after the user's explicit start signal. 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 2488a36..8cee0af 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 @@ -4,20 +4,22 @@ `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`, `WS-POL-001-10`, and `WS-POL-001-11` are merged to `main`. +`WS-POL-001-09`, `WS-POL-001-10`, `WS-POL-001-11`, and `WS-POL-001-12` are +merged to `main`. The post-actor-registry Terminal Benchmark live API drill passed through real -HTTP calls, but it required DB inspection for project setup outputs and locked -context verification. `WS-POL-001-12` is implemented in PR #76 to expose the -first project setup and project policy visibility APIs. `WS-POL-001-13` and -`WS-POL-001-14` remain the next visibility/finalize chunks before rerunning the -drill without DB inspection. +HTTP calls, but it still needs task context visibility, submission finalize +wording, and system-actor audit semantics before being accepted as a no-DB +proof. `WS-POL-001-13` is active to expose worker-safe task context, exact +submission requirements, and operator-only locked provenance. `WS-POL-001-14` +remains the finalize/no-DB proof chunk before rerunning the Terminal Benchmark +drill as accepted evidence. ## Active Chunk -`WS-POL-001-12` is implemented on branch -`codex/ws-pol-001-12-project-setup-policy-visibility` in PR #76. Internal -review is complete, CodeRabbit feedback is being addressed, and the chunk is -not merged until the user explicitly approves PR #76. +`WS-POL-001-13` is active on branch +`codex/ws-pol-001-13-task-context-apis`. It adds task work-context, +submission-requirements, and operator locked-context APIs before the next +Terminal Benchmark drill. ## Chunk Status @@ -34,16 +36,15 @@ not merged until the user explicitly approves PR #76. | `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` | Merged | `codex/ws-pol-001-11-actor-profile-registry-impl` | 74 | Implements local Workstream actor identity and actor profile registries for verified Flow actors before the next live API drill. | -| `WS-POL-001-12` | PR open | `codex/ws-pol-001-12-project-setup-policy-visibility` | 76 | Adds project setup-run and project policy visibility APIs for setup runs, sufficiency reports, submission artifact policies, effective policy, and compiled project pre-submit checker policy; awaiting human merge review after external feedback fixes. | -| `WS-POL-001-13` | Proposed | - | - | Add task work-context, worker submission-requirements, and operator locked-context APIs. | +| `WS-POL-001-12` | Merged | `codex/ws-pol-001-12-project-setup-policy-visibility` | 76 | Adds project setup-run and project policy visibility APIs for setup runs, sufficiency reports, submission artifact policies, effective policy, and compiled project pre-submit checker policy. | +| `WS-POL-001-13` | Active | `codex/ws-pol-001-13-task-context-apis` | - | Add task work-context, worker submission-requirements, and operator locked-context APIs. | | `WS-POL-001-14` | Proposed | - | - | Replace public submission lock with finalize, define system actor audit semantics, and rerun the Terminal Benchmark proof without DB inspection. | ## Blockers | Blocker | Owner | Next action | |---|---|---| -| PR #76 human merge review | Human reviewer | Review and explicitly approve or request changes for `WS-POL-001-12`. | -| Missing task context visibility APIs | Workstream | Implement `WS-POL-001-13`. | +| Missing task context visibility APIs | Workstream | Implement and review `WS-POL-001-13`. | | Public lock wording and no-DB drill proof | Workstream | Implement `WS-POL-001-14` before rerunning Terminal Benchmark as accepted proof. | ## Follow-Ups diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-13-external-review-response.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-13-external-review-response.md new file mode 100644 index 0000000..b4e3c4d --- /dev/null +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-13-external-review-response.md @@ -0,0 +1,59 @@ +# External Review Response: WS-POL-001-13 + +## PR + +PR #77: https://github.com/Flow-Research/workstream/pull/77 + +## Chunk + +`WS-POL-001-13` + +## Source + +CodeRabbit + +## Summary + +CodeRabbit reported one valid test-maintainability nitpick and one PR +description warning. No product, architecture, security, authorization, or API +contract changes were requested. + +## External Findings + +| Source | Finding | Severity | Status | Response | +|---|---|---:|---:|---| +| CodeRabbit | The malformed locked-context regression test read `pre_submit_policy.compiled_bundle` after `session.delete()` and `flush()`. | Trivial | Addressed | Captured `original_compiled_bundle` before deleting the ORM row and used the saved bundle when constructing the malformed checker bundle. | +| CodeRabbit | The PR description omitted explicit template sections for chunk, goal, changed behavior, acceptance proof, and test delta. | Warning | Addressed | Reshaped the PR trust bundle and PR body around the required review sections. | + +## Fix plan + +- Update `backend/tests/test_tasks.py` so the malformed locked-context test does + not dereference a deleted ORM instance. +- Update the PR trust bundle with explicit review-template headings. +- Record this external review response separately from internal review + evidence. +- Update the GitHub PR body from the corrected trust bundle. + +## Out-of-scope items to defer + +None. + +## Evidence after fixes + +```bash +cd backend && .venv/bin/pytest tests/test_tasks.py::test_submission_requirements_fail_closed_on_hash_consistent_malformed_policy_shape -q +cd backend && .venv/bin/python -m ruff check tests/test_tasks.py +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_markdown_links.py +git diff --check +``` + +Result summary: + +```text +Focused pytest: 1 passed +Ruff: All checks passed +Stale wording: passed +Markdown links: passed for changed Markdown files +git diff --check: passed +``` diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-13-internal-review-evidence.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-13-internal-review-evidence.md new file mode 100644 index 0000000..5c2300c --- /dev/null +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-13-internal-review-evidence.md @@ -0,0 +1,93 @@ +# Internal Review Evidence: WS-POL-001-13 + +## Chunk + +WS-POL-001-13 + +open sub-agent sessions: none + +valid findings addressed: yes + +## Reviewed Revision + +Reviewed code SHA: f533f1a572a38d4e8ecd34ff6316885c6c6b1016 + +Reviewed at: 2026-07-08T02:09:55Z + +Reviewer run IDs: senior-engineering-final-019f3ecb-6123-7581-9b08-a1812b789d57, qa-test-final-019f3ecb-a018-7e02-81a7-3a7fd102a4ba, security-auth-final-019f3ecb-d5a9-7800-94b3-e4358974f0cd, product-ops-final-019f3ecc-0e2d-7e90-873a-af311a10fd59, architecture-final-019f3ecc-5034-7da0-a53e-1cb66880535b, docs-final-019f3ecc-87cf-75a3-b806-d694cf0f8114, reuse-dedup-final-019f3ed0-bdf1-7ae3-99ae-a33db36dea1c, test-delta-final-019f3ed0-f9d9-7590-a829-de92b9296105, senior-engineering-external-019f3f6d-441f-7340-9bcc-ef92155d956d, qa-test-external-019f3f6d-4986-7612-a7a7-9457a2182d0b, security-auth-external-019f3f6d-4fea-7890-a372-a261d2482b91, product-ops-external-019f3f6d-5758-79d3-a7d4-3d58933064da, docs-external-final-019f3f76-dd39-74f3-8cf3-d551908fa058, test-delta-external-019f3f6d-685a-7300-82ab-966c3c034976 + +After the reviewed SHA, only evidence files changed. + +## Reviewed Change + +Scope: + +- Adds `GET /api/v1/tasks/{task_id}/work-context` for worker-safe task, project, guide, payment, review/revision version, and lifecycle context. +- Adds `GET /api/v1/tasks/{task_id}/submission-requirements` for exact locked submission packet fields, artifacts, evidence, storage rules, packaging, hash algorithm, and attestation terms. +- Adds `GET /api/v1/tasks/{task_id}/locked-context` for `admin` and `project_manager` locked provenance. +- Validates task-stamped guide source snapshot, effective project policy, project pre-submit checker, post-submit checker, review, revision, and payment context before response projection. +- Keeps worker-facing responses free of private source/import refs, assignment provenance, raw compiled bundles, checker configs, setup internals, source snapshot hashes, and policy provenance hashes. +- Redacts existing worker-visible `GET /tasks/{task_id}` responses so workers cannot bypass `work-context` redaction. +- Fails closed with structured `task_locked_context_invalid` when locked context is missing, stale, hash-mismatched, or hash-consistent but malformed. +- Extends the real API contract drill to call the three task-context APIs through HTTP. + +## Reviewer Results + +| Reviewer | Result | Blocking findings | Notes | +|---|---:|---|---| +| senior engineering | PASS | None | Confirmed router/service boundaries, route-specific domain schemas, fail-closed malformed policy behavior, and worker redaction. | +| QA/test | PASS | None | Confirmed acceptance criteria, exact requirements including `package_hash`, v1 lock after v2 activation, and live API drill coverage. | +| security/auth | PASS WITH LOW RISKS | None | Confirmed no private values/hashes/configs leak. Low residual risk: worker-facing structured errors include internal field names without values. | +| product/ops | PASS | None | Confirmed worker and operator workflows, existing task read redaction, and no payment/reputation behavior expansion. | +| architecture | PASS | None | Confirmed stamped-context reads, no current-guide recompute, operator-only locked context, and allowed-file scope. | +| docs | PASS | None | Confirmed glossary, architecture, operations, and roadmap docs align with the new API surfaces. | +| reuse/dedup | PASS WITH LOW RISKS | None | Low residual risk: required packet field constants mirror `SubmissionCreate`; future shared extraction can avoid drift. | +| test delta | PASS WITH LOW RISKS | None | Confirmed strengthened tests and no skips/removals. Low residual risk: locked-context response shape is sampled rather than fully enumerated. | + +## Valid Findings Addressed + +- Replaced broad worker `TaskResponse` usage in `work-context` with `TaskWorkerTaskContext`. +- Removed private source/import/creator/assignment fields from worker `work-context` task data. +- Stopped exposing mutable same-version review/revision/payment row details in worker work context; response now uses stamped guide/payment values and guide-version references. +- Added route-specific domain error schemas so submission creation documents only `pre_submission_checker_failed` and task-context endpoints document only `task_locked_context_invalid`. +- Added `package_hash` to the exact worker-facing required packet field list. +- Removed `forbidden_artifacts.source` from worker-facing submission requirements. +- Redacted private source/import/creator/assignment fields from existing worker-visible `GET /tasks/{task_id}` responses. +- Added explicit locked effective-policy projection validation so malformed hash-consistent policy shapes return structured `task_locked_context_invalid`. +- Centralized locked-context required fields and reused the shared missing-field helper. +- Added tests for worker-safe redaction, operator-only locked context, exact submission requirements, malformed policy fail-closed behavior, same-version policy mutation, and v1 locked context after v2 guide activation. +- Updated the real API contract drill to call `work-context`, `submission-requirements`, and `locked-context`. +- Addressed CodeRabbit's test-maintainability nitpick by capturing the compiled + bundle before deleting the ORM row in the malformed locked-context + regression. + +## Commands Run + +```bash +cd backend && .venv/bin/python -m ruff check app tests scripts +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_markdown_links.py +git diff --check +rg -n "task binding|task-owned policy|direct DB|DB inspection" docs backend examples .agent-loop +cd backend && .venv/bin/pytest tests/test_tasks.py::test_worker_task_response_redacts_locked_policy_hashes tests/test_tasks.py::test_assigned_worker_submits_v1_and_task_moves_to_submitted tests/test_tasks.py::test_task_context_apis_return_worker_requirements_and_operator_provenance tests/test_tasks.py::test_submission_requirements_fail_closed_on_hash_consistent_malformed_policy_shape -q +cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py +cd backend && .venv/bin/pytest tests/test_tasks.py +``` + +Results: + +- Ruff: passed. +- Stale wording scan: passed. +- Markdown link check: passed for 7 changed Markdown files. +- Diff whitespace check: passed. +- Stale model/no-DB scan: expected references only in chunk contracts, status/decision docs, and the documented future no-DB proof path; no active per-task policy wording was found. +- Focused task context and worker redaction regressions: 4 passed in 43.18s. +- API contract real API E2E: passed and called `work-context`, `submission-requirements`, and `locked-context` through HTTP. +- Full task suite: 81 passed in 743.67s. + +## Remaining Risks + +- Worker-facing locked-context errors include internal field names such as locked context field identifiers. They do not expose values, hashes, private refs, compiled bundles, checker configs, or source material. A future UX/security hardening pass can map worker errors to public categories while preserving exact operator diagnostics. +- `SUBMISSION_CREATE_REQUIRED_PACKET_FIELDS` mirrors `SubmissionCreate` required fields. It is centralized for this projection, but a future cleanup can derive it from schema metadata or move the contract beside the schema to reduce drift. +- The task context loader repeats some locked pre-submit validation concepts also used by checker execution. The current direction is acceptable because this API needs task-specific domain errors and richer context; if another reader appears, extract a shared locked-context validator. +- The next Terminal Benchmark no-DB drill is still assigned to `WS-POL-001-14`. diff --git a/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-13-pr-trust-bundle.md b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-13-pr-trust-bundle.md new file mode 100644 index 0000000..47f779f --- /dev/null +++ b/.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-13-pr-trust-bundle.md @@ -0,0 +1,262 @@ +# PR Trust Bundle: WS-POL-001-13 + +## Chunk + +`WS-POL-001-13` - Task context and submission requirement APIs. + +## Goal + +Expose task context and submission requirements through authorized HTTP APIs so +workers and operators no longer need to infer locked requirements from failures +or inspect Postgres during the next live drill. + +## Human-approved intent + +Chunk contract: + +- `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/chunks/WS-POL-001-13-task-context-submission-requirements-apis.md` + +The user approved exposing worker-safe task context, exact submission +requirements, and operator-only locked provenance APIs before rerunning the +Terminal Benchmark drill. + +## What changed + +Implemented APIs 8-10 only: + +- `GET /api/v1/tasks/{task_id}/work-context` +- `GET /api/v1/tasks/{task_id}/submission-requirements` +- `GET /api/v1/tasks/{task_id}/locked-context` + +The implementation also redacts existing worker-visible task reads so +`GET /tasks/{task_id}` cannot bypass the worker-safe projection. + +## Why it changed + +The Terminal Benchmark drill showed that setup and task policy state was still +too dependent on direct database inspection. This chunk exposes the +already-locked task context through authorized APIs while preserving the rule +that runtime uses stamped policy references, not recomputed current project +state. + +## Design chosen + +Task context APIs read the task's already-stamped locked context. They do not +recompute from the current active guide or current project policy. + +Worker-facing APIs return: + +- safe task summary +- project and locked guide summary +- stamped payment terms +- review/revision guide-version references +- lifecycle next actions +- exact submission packet fields, including `package_hash` +- artifact, evidence, storage, packaging, hash, and attestation requirements + +Operator-only `locked-context` returns full locked provenance for `admin` and +`project_manager`. + +Authorization: + +- `work-context`: existing task visibility for `admin`, `project_manager`, or + eligible/assigned worker. +- `submission-requirements`: existing task visibility for `admin`, + `project_manager`, or eligible/assigned worker. +- `locked-context`: `admin` or `project_manager` only. +- Persisted actor profiles do not grant route authorization; token-derived + roles remain the route gate. + +## Alternatives rejected + +- Recomputing task requirements from the current active guide at read time: + rejected because tasks must expose the stamped context they already locked. +- Exposing compiled bundles, checker configs, source refs, or private policy + internals to workers: rejected because worker-facing APIs should show what + must be submitted, not checker authority. +- Continuing to inspect Postgres during the live drill: rejected because the + next drill must prove the public/operator API surface. + +## Scope control + +### Allowed files changed + +- `backend/app/modules/tasks/router.py` +- `backend/app/modules/tasks/schemas.py` +- `backend/app/modules/tasks/service.py` +- `backend/scripts/api_contract_e2e.py` +- `backend/tests/test_tasks.py` +- `docs/architecture_data_model.md` +- `docs/architecture_system_architecture.md` +- `docs/glossary.md` +- `docs/operations_project_operating_manual.md` +- `docs/roadmap_status.md` +- `.agent-loop/LOOP_STATE.md` +- `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/STATUS.md` +- `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-13-internal-review-evidence.md` +- `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-13-external-review-response.md` +- `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-13-pr-trust-bundle.md` + +### Files outside scope + +None. + +## Product Behavior + +- [ ] No Workstream product behavior changed. +- [x] Product behavior changed and is explained here: + +Workers and operators can now inspect task context and submission requirements +through authorized APIs. Worker responses are intentionally redacted. Operators +with `admin` or `project_manager` can inspect full locked provenance. + +## Acceptance criteria proof + +- [x] Worker-safe task context API exists and reads locked context. + Evidence: `test_task_context_apis_return_worker_requirements_and_operator_provenance`. +- [x] Submission requirements API returns exact packet, artifact, evidence, + storage, packaging, hash, and attestation requirements. + Evidence: `test_task_context_apis_return_worker_requirements_and_operator_provenance` + and `scripts/api_contract_e2e.py`. +- [x] Operator locked-context API is restricted to `admin` and + `project_manager`. + Evidence: `test_task_context_apis_return_worker_requirements_and_operator_provenance`. +- [x] Missing, stale, or malformed locked context fails closed. + Evidence: `test_submission_requirements_fail_closed_on_hash_consistent_malformed_policy_shape`. +- [x] Worker-facing task reads remain redacted. + Evidence: `test_worker_task_response_redacts_locked_policy_hashes`. + +## Tests/checks run + +```bash +cd backend && .venv/bin/python -m ruff check app tests scripts +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_markdown_links.py +git diff --check +cd backend && .venv/bin/pytest tests/test_tasks.py::test_worker_task_response_redacts_locked_policy_hashes tests/test_tasks.py::test_assigned_worker_submits_v1_and_task_moves_to_submitted tests/test_tasks.py::test_task_context_apis_return_worker_requirements_and_operator_provenance tests/test_tasks.py::test_submission_requirements_fail_closed_on_hash_consistent_malformed_policy_shape -q +cd backend && WORKSTREAM_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/python scripts/api_contract_e2e.py +cd backend && .venv/bin/pytest tests/test_tasks.py +cd backend && .venv/bin/pytest tests/test_tasks.py::test_submission_requirements_fail_closed_on_hash_consistent_malformed_policy_shape -q +cd backend && .venv/bin/python -m ruff check tests/test_tasks.py +``` + +Result summary: + +```text +Focused task-context and worker-redaction regressions: 4 passed +API contract real API E2E: passed with the three new task-context endpoints +Full task suite: 81 passed in 743.67s +CodeRabbit regression pytest: 1 passed +Ruff: passed +Stale wording: passed +Markdown links: passed +git diff --check: passed +``` + +## Test delta + +### Tests added + +- Added task API coverage for worker-facing work context and submission + requirements. +- Added operator-only locked-context API coverage. +- Added fail-closed locked-context validation coverage. +- Added regression coverage for worker redaction on existing task reads. +- Extended the real API contract drill to call the three new endpoints. + +### Tests modified + +- Updated `test_submission_requirements_fail_closed_on_hash_consistent_malformed_policy_shape` + after CodeRabbit review to capture the compiled bundle before deleting the + ORM row. + +### Tests removed/skipped + +None. + +## CI integrity + +- [x] Coverage threshold unchanged +- [x] Lint unchanged +- [x] Typecheck unchanged +- [x] No workflow weakening +- [x] No package script weakening +- [x] No unpinned new GitHub Action +- [x] Checkout credential persistence unchanged + +## External review + +External review response file: + +- `.agent-loop/initiatives/WS-POL-001-submission-artifact-policy-foundation/reviews/WS-POL-001-13-external-review-response.md` + +| Source | Status | Notes | +|---|---:|---| +| CodeRabbit | Addressed locally | One valid test-maintainability nitpick and one PR-description warning were fixed. | +| GitHub checks | Pending rerun | Existing checks passed before this external-review response commit; they must rerun after push. | + +## Reviewer results + +Reviewed implementation SHA: `f533f1a572a38d4e8ecd34ff6316885c6c6b1016` + +Reviewed at: 2026-07-08T02:09:55Z + +Reviewer run IDs: + +- senior engineering: `019f3f6d-441f-7340-9bcc-ef92155d956d` +- QA/test: `019f3f6d-4986-7612-a7a7-9457a2182d0b` +- security/auth: `019f3f6d-4fea-7890-a372-a261d2482b91` +- product/ops: `019f3f6d-5758-79d3-a7d4-3d58933064da` +- docs: `019f3f6d-6062-7590-8358-3bc0766e935d` +- reuse/dedup: original internal review evidence +- test delta: `019f3f6d-685a-7300-82ab-966c3c034976` + +| Reviewer | Result | Blocking findings | Notes | +|---|---:|---|---| +| senior engineering | PASS | None | Confirmed the CodeRabbit test fix is minimal. | +| QA/test | PASS WITH LOW RISKS | None | Confirmed coverage is not weakened. | +| security/auth | PASS | None | Confirmed no auth or data-leakage risk in the delta. | +| product/ops | PASS | None | Confirmed external review stays separate from internal evidence and human merge ownership remains intact. | +| architecture | PASS | None | Original internal review result. | +| CI integrity | N/A - with approved reason | None | No CI/workflow/package-script files changed in the external-review response delta. | +| docs | PASS AFTER FIXES | None | Initial template-format finding was fixed. | +| reuse/dedup | PASS WITH LOW RISKS | None | Original internal review result. | +| test delta | PASS | None | Confirmed the test still proves fail-closed malformed policy behavior. | + +All sub-agent sessions were closed before final reporting. + +## Remaining risks + +- Worker-facing locked-context errors include internal field names, but no + values, hashes, source refs, bundles, or configs. +- Required packet field constants mirror `SubmissionCreate` and should be kept + in sync until a shared schema-derived helper is extracted. +- Full no-DB Terminal Benchmark proof remains assigned to `WS-POL-001-14`. + +## Follow-up work + +- `WS-POL-001-14`: replace public submission lock wording with finalize, + define system actor audit semantics, and rerun the Terminal Benchmark proof + without DB inspection. +- Later cleanup: extract a shared schema-derived helper for required packet + fields if another caller needs the same constants. + +## Human review focus + +Please inspect: + +- Confirm worker-facing requirements are complete enough to submit without + exposing internal checker authority. +- Confirm existing `GET /tasks/{task_id}` redaction is acceptable for workers. +- Confirm `locked-context` exposes the right operator provenance and remains + restricted to `admin` and `project_manager`. +- Confirm `task_locked_context_invalid` is the right public error code for + missing/stale/malformed task locked context. + +## Human ownership + +- [ ] I can explain what changed. +- [ ] I can explain why it changed. +- [ ] I know what could break. +- [ ] I accept the remaining risks. +- [ ] The user explicitly approved this specific PR for merge. diff --git a/backend/app/modules/tasks/router.py b/backend/app/modules/tasks/router.py index 70a5be5..4b080f4 100644 --- a/backend/app/modules/tasks/router.py +++ b/backend/app/modules/tasks/router.py @@ -16,10 +16,13 @@ from app.modules.tasks.schemas import ( AuditEventResponse, SubmissionCreate, + SubmissionRequirementsResponse, SubmissionResponse, TaskCreate, + TaskLockedContextResponse, TaskResponse, TaskTransitionRequest, + TaskWorkContextResponse, TaskWithAssignmentResponse, ) from app.modules.tasks.service import TaskService, TaskServiceError @@ -27,7 +30,7 @@ router = APIRouter(tags=["tasks"]) -DOMAIN_ERROR_RESPONSE_SCHEMA = { +PRE_SUBMIT_DOMAIN_ERROR_RESPONSE_SCHEMA = { "oneOf": [ { "type": "object", @@ -44,6 +47,23 @@ {"$ref": "#/components/schemas/HTTPValidationError"}, ] } +TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA = { + "oneOf": [ + { + "type": "object", + "required": ["code", "details"], + "properties": { + "code": { + "type": "string", + "enum": ["task_locked_context_invalid"], + }, + "details": {"type": "object"}, + }, + "additionalProperties": False, + }, + {"$ref": "#/components/schemas/HTTPValidationError"}, + ] +} def task_http_error(exc: TaskServiceError) -> HTTPException: @@ -144,6 +164,99 @@ async def get_task( raise task_http_error(exc) from exc +@router.get( + "/tasks/{task_id}/work-context", + response_model=TaskWorkContextResponse, + response_model_exclude_none=True, + responses={ + 422: { + "description": "Locked task context is missing or inconsistent.", + "content": { + "application/json": { + "schema": TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA + } + }, + } + }, +) +async def get_task_work_context( + task_id: str, + actor: Annotated[ActorContext, Depends(get_registered_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> TaskWorkContextResponse | JSONResponse: + """Return worker-safe locked guide, policy, and lifecycle context.""" + try: + return await TaskService(session).get_task_work_context(actor, task_id) + except PermissionDenied as exc: + raise permission_http_error(exc) from exc + except TaskServiceError as exc: + if getattr(exc, "code", None) is not None: + return task_domain_error_response(exc) + raise task_http_error(exc) from exc + + +@router.get( + "/tasks/{task_id}/submission-requirements", + response_model=SubmissionRequirementsResponse, + response_model_exclude_none=True, + responses={ + 422: { + "description": "Locked task context is missing or inconsistent.", + "content": { + "application/json": { + "schema": TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA + } + }, + } + }, +) +async def get_task_submission_requirements( + task_id: str, + actor: Annotated[ActorContext, Depends(get_registered_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> SubmissionRequirementsResponse | JSONResponse: + """Return exact worker submission requirements from locked policy context.""" + try: + return await TaskService(session).get_task_submission_requirements(actor, task_id) + except PermissionDenied as exc: + raise permission_http_error(exc) from exc + except TaskServiceError as exc: + if getattr(exc, "code", None) is not None: + return task_domain_error_response(exc) + raise task_http_error(exc) from exc + + +@router.get( + "/tasks/{task_id}/locked-context", + response_model=TaskLockedContextResponse, + response_model_exclude_none=True, + responses={ + 422: { + "description": "Locked task context is missing or inconsistent.", + "content": { + "application/json": { + "schema": TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA + } + }, + } + }, +) +async def get_task_locked_context( + task_id: str, + actor: Annotated[ActorContext, Depends(get_registered_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> TaskLockedContextResponse | JSONResponse: + """Return operator-only locked task provenance.""" + try: + return await TaskService(session).get_task_locked_context(actor, task_id) + except PermissionDenied as exc: + raise permission_http_error(exc) from exc + except TaskServiceError as exc: + if getattr(exc, "code", None) is not None: + return task_domain_error_response(exc) + raise task_http_error(exc) from exc + + @router.post( "/tasks/{task_id}/screen", response_model=TaskResponse, @@ -250,7 +363,7 @@ async def start_task( "description": "Pre-submit domain failure or request validation error.", "content": { "application/json": { - "schema": DOMAIN_ERROR_RESPONSE_SCHEMA, + "schema": PRE_SUBMIT_DOMAIN_ERROR_RESPONSE_SCHEMA, } }, } diff --git a/backend/app/modules/tasks/schemas.py b/backend/app/modules/tasks/schemas.py index e08f1c0..eb3f3f0 100644 --- a/backend/app/modules/tasks/schemas.py +++ b/backend/app/modules/tasks/schemas.py @@ -180,12 +180,192 @@ class TaskResponse(BaseModel): acceptance_criteria: str | None rejection_criteria: str | None deadline_at: datetime | None - created_by: str + created_by: str | None assigned_to: str | None created_at: datetime updated_at: datetime +class TaskProjectContext(BaseModel): + """Worker-safe project summary for a task context response.""" + + id: str + name: str + slug: str + description: str | None + + +class TaskWorkerTaskContext(BaseModel): + """Worker-safe task summary for work-context responses.""" + + id: str + project_id: str + locked_guide_version: str + title: str + description: str + task_type: str | None + difficulty: str | None + skill_tags: list[str] + estimated_time_minutes: int | None + base_amount: Decimal | None + currency: str | None + payout_type: str | None + status: str + acceptance_criteria: str | None + rejection_criteria: str | None + deadline_at: datetime | None + created_at: datetime + updated_at: datetime + + +class TaskGuideContext(BaseModel): + """Worker-safe guide material locked to a task.""" + + id: str + version: str + content_markdown: str + change_summary: str | None + effective_at: datetime | None + + +class TaskReviewPolicyContext(BaseModel): + """Worker-safe review policy summary for the locked guide version.""" + + guide_version: str + + +class TaskRevisionPolicyContext(BaseModel): + """Worker-safe revision policy summary for the locked guide version.""" + + guide_version: str + + +class TaskPaymentPolicyContext(BaseModel): + """Worker-safe payment terms stamped onto the task at screening.""" + + guide_version: str + base_amount: Decimal | None + currency: str | None + payout_type: str | None + + +class TaskWorkerLifecycleContext(BaseModel): + """Worker-facing lifecycle state for a task.""" + + status: str + assigned_to_current_actor: bool + can_run_pre_submit_check: bool + can_submit: bool + next_actions: list[str] + + +class TaskWorkContextResponse(BaseModel): + """Worker-safe context needed before doing task work.""" + + task: TaskWorkerTaskContext + project: TaskProjectContext + guide: TaskGuideContext + review_policy: TaskReviewPolicyContext + revision_policy: TaskRevisionPolicyContext + payment_policy: TaskPaymentPolicyContext + lifecycle: TaskWorkerLifecycleContext + + +class RequiredArtifactRequirement(BaseModel): + """Worker-facing required artifact rule from the locked effective policy.""" + + key: str + path: str + hash_required: bool + required: bool + description: str | None = None + + +class RequiredEvidenceRequirement(BaseModel): + """Worker-facing required evidence rule from the locked effective policy.""" + + key: str + label: str + hash_required: bool + required: bool + description: str | None = None + + +class ForbiddenArtifactRequirement(BaseModel): + """Worker-facing forbidden artifact rule from the locked effective policy.""" + + pattern: str + reason: str | None = None + worker_facing_fix: str | None = None + severity: str | None = None + + +class StorageReferenceRules(BaseModel): + """Worker-facing storage-reference constraints for staged artifacts.""" + + allowed_storage_schemes: list[str] + allowed_uri_prefixes: list[str] + credentials_allowed: bool + query_strings_allowed: bool + fragments_allowed: bool + path_traversal_allowed: bool + + +class SubmissionRequirementsResponse(BaseModel): + """Worker-safe exact submission requirements for a locked task.""" + + task_id: str + project_id: str + guide_version: str + policy_schema_version: str | None + merge_algorithm_version: str | None + required_packet_fields: list[str] + required_artifacts: list[RequiredArtifactRequirement] + required_evidence: list[RequiredEvidenceRequirement] + forbidden_artifacts: list[ForbiddenArtifactRequirement] + attestation_terms: list[str] + manifest_required: bool + artifact_hash_required: bool + artifact_hash_algorithm: Literal["sha256"] + allowed_storage_schemes: list[str] + storage_reference_rules: StorageReferenceRules + maximum_file_size_bytes: int | None + maximum_package_size_bytes: int | None + packaging: dict[str, Any] + + +class PostSubmitPolicyBodySummary(BaseModel): + """Operator-facing summary of the locked post-submit checker policy body.""" + + schema_version: str | None + default_checkers: list[str] + required_checkers: list[str] + warning_checkers: list[str] + execution_checkers: list[str] + blocking_severities: list[str] + + +class TaskLockedContextResponse(BaseModel): + """Operator-only locked provenance for a task.""" + + task_id: str + project_id: str + locked_guide_version: str + locked_guide_source_snapshot_id: str + locked_guide_source_snapshot_hash: str + locked_effective_project_submission_artifact_policy_id: str + locked_effective_project_submission_artifact_policy_hash: str + locked_pre_submit_checker_policy_id: str + locked_pre_submit_checker_bundle_hash: str + locked_post_submit_checker_policy_id: str + locked_post_submit_checker_policy_version: str + locked_post_submit_checker_policy_hash: str + locked_post_submit_checker_policy_body_summary: PostSubmitPolicyBodySummary + locked_review_policy_version: str + locked_revision_policy_version: str + locked_payment_policy_version: str + + class AssignmentResponse(BaseModel): """Response schema for task assignments.""" diff --git a/backend/app/modules/tasks/service.py b/backend/app/modules/tasks/service.py index c048406..277c8ba 100644 --- a/backend/app/modules/tasks/service.py +++ b/backend/app/modules/tasks/service.py @@ -2,13 +2,20 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import UTC, datetime +from typing import Any from uuid import uuid4 from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from app.core.hashing import canonical_json_hash from app.core.permissions import require_any_role +from app.modules.checkers.compiler import ( + PreSubmitCheckerCompilerError, + validate_compiled_pre_submit_checker_bundle, +) from app.modules.actors.models import ActorProfile from app.modules.actors.service import ActorService from app.modules.projects.models import ( @@ -17,6 +24,7 @@ PaymentPolicy, PostSubmitCheckerPolicy, PreSubmitCheckerPolicy, + Project, ProjectGuide, RevisionPolicy, ReviewPolicy, @@ -47,10 +55,25 @@ from app.modules.tasks.schemas import ( AssignmentResponse, AuditEventResponse, + ForbiddenArtifactRequirement, + PostSubmitPolicyBodySummary, + RequiredArtifactRequirement, + RequiredEvidenceRequirement, + StorageReferenceRules, SubmissionCreate, + SubmissionRequirementsResponse, SubmissionResponse, TaskCreate, + TaskGuideContext, + TaskLockedContextResponse, + TaskPaymentPolicyContext, + TaskProjectContext, TaskResponse, + TaskReviewPolicyContext, + TaskRevisionPolicyContext, + TaskWorkerLifecycleContext, + TaskWorkerTaskContext, + TaskWorkContextResponse, TaskWithAssignmentResponse, ) from app.schemas.auth import ActorContext @@ -80,6 +103,28 @@ "pre_review_gate_needs_revision", "pre_review_gate_blocked", } +SUBMISSION_CREATE_REQUIRED_PACKET_FIELDS = ( + "summary", + "package_hash", + "artifact_hash_manifest", + "worker_attestation", +) +LOCKED_CONTEXT_REQUIRED_FIELDS = ( + "locked_guide_version", + "locked_post_submit_checker_policy_id", + "locked_post_submit_checker_policy_version", + "locked_post_submit_checker_policy_hash", + "locked_post_submit_checker_policy_body", + "locked_review_policy_version", + "locked_revision_policy_version", + "locked_payment_policy_version", + "locked_guide_source_snapshot_id", + "locked_guide_source_snapshot_hash", + "locked_effective_project_submission_artifact_policy_id", + "locked_effective_project_submission_artifact_policy_hash", + "locked_pre_submit_checker_policy_id", + "locked_pre_submit_checker_bundle_hash", +) class TaskServiceError(Exception): @@ -162,6 +207,39 @@ def __init__(self, details: dict) -> None: self.details = details +class TaskLockedContextInvalid(TaskServiceError): + """Raised when a task's stamped policy provenance is missing or inconsistent.""" + + status_code = 422 + code = "task_locked_context_invalid" + + def __init__(self, message: str, details: dict[str, Any] | None = None) -> None: + """Create a locked-context error with machine-readable details. + + Args: + message: Human-readable error summary safe for API responses. + details: Optional structured error details. + """ + super().__init__(message) + self.details = details or {"message": message} + + +@dataclass(frozen=True) +class LockedTaskContext: + """Validated records bound to one task's stamped locked context.""" + + project: Project + guide: ProjectGuide + source_snapshot: GuideSourceSnapshot + effective_policy: EffectiveProjectSubmissionArtifactPolicy + pre_submit_checker_policy: PreSubmitCheckerPolicy + post_submit_checker_policy: PostSubmitCheckerPolicy + locked_post_submit_policy_body: PostSubmitPolicyBodySummary + review_policy: ReviewPolicy + revision_policy: RevisionPolicy + payment_policy: PaymentPolicy + + class TaskService: """Coordinates task lifecycle rules, assignment, and audit writes.""" @@ -254,6 +332,80 @@ async def get_task(self, actor: ActorContext, task_id: str) -> TaskResponse: await self._ensure_task_visible(actor, task) return self._task_response(actor, task) + async def get_task_work_context( + self, + actor: ActorContext, + task_id: str, + ) -> TaskWorkContextResponse: + """Return worker-safe work context from the task's locked provenance. + + Args: + actor: Verified Flow actor context for the current request. + task_id: Task whose context should be returned. + + Returns: + Worker-facing task, guide, policy, and lifecycle context. + + Raises: + PermissionDenied: If the actor cannot view tasks. + TaskNotFound: If the task is unknown or hidden. + TaskLockedContextInvalid: If locked context is incomplete or stale. + """ + require_any_role(actor, TASK_VIEW_ROLES) + task = await self._get_task(task_id) + await self._ensure_task_visible(actor, task) + context = await self._load_locked_task_context(task) + return self._work_context_response(actor, task, context) + + async def get_task_submission_requirements( + self, + actor: ActorContext, + task_id: str, + ) -> SubmissionRequirementsResponse: + """Return exact worker submission requirements for a locked task. + + Args: + actor: Verified Flow actor context for the current request. + task_id: Task whose locked requirements should be returned. + + Returns: + Worker-facing submission artifact requirements. + + Raises: + PermissionDenied: If the actor cannot view tasks. + TaskNotFound: If the task is unknown or hidden. + TaskLockedContextInvalid: If locked context is incomplete or stale. + """ + require_any_role(actor, TASK_VIEW_ROLES) + task = await self._get_task(task_id) + await self._ensure_task_visible(actor, task) + context = await self._load_locked_task_context(task) + return self._submission_requirements_response(task, context) + + async def get_task_locked_context( + self, + actor: ActorContext, + task_id: str, + ) -> TaskLockedContextResponse: + """Return operator-only locked provenance for a task. + + Args: + actor: Verified Flow actor context for the current request. + task_id: Task whose locked provenance should be returned. + + Returns: + Full locked guide and policy provenance for support/debugging. + + Raises: + PermissionDenied: If the actor is not an operator. + TaskNotFound: If the task is unknown. + TaskLockedContextInvalid: If locked context is incomplete or stale. + """ + require_any_role(actor, PROJECT_OPERATOR_ROLES) + task = await self._get_task(task_id) + context = await self._load_locked_task_context(task) + return self._locked_context_response(task, context) + async def move_to_screening( self, actor: ActorContext, @@ -895,6 +1047,597 @@ async def _load_active_policy_context( pre_submit_checker_policy, ) + async def _load_locked_task_context(self, task: WorkstreamTask) -> LockedTaskContext: + """Load and validate every row stamped into a task's locked context. + + Args: + task: Task whose locked context is authoritative. + + Returns: + Validated locked task context rows. + + Raises: + TaskLockedContextInvalid: If any stamped row is missing, stale, or + inconsistent with the task's locked ids and hashes. + """ + missing = self._missing_locked_context_fields(task) + if missing: + raise TaskLockedContextInvalid( + "task locked context is incomplete", + {"missing_fields": missing}, + ) + + project = await self._project_repo.get_project(task.project_id) + guide = await self._project_repo.get_guide_by_version( + task.project_id, + task.locked_guide_version or "", + ) + if project is None or guide is None or guide.project_id != task.project_id: + raise TaskLockedContextInvalid( + "task locked guide context is invalid", + {"field": "locked_guide_version"}, + ) + + source_snapshot = await self._project_repo.get_guide_source_snapshot( + task.locked_guide_source_snapshot_id or "", + ) + if ( + source_snapshot is None + or source_snapshot.project_id != task.project_id + or source_snapshot.guide_version != task.locked_guide_version + or source_snapshot.bundle_hash != task.locked_guide_source_snapshot_hash + or canonical_json_hash(source_snapshot.manifest_json) != source_snapshot.bundle_hash + ): + raise TaskLockedContextInvalid( + "task locked guide source snapshot is invalid", + {"field": "locked_guide_source_snapshot_hash"}, + ) + + effective_policy = ( + await self._project_repo.get_effective_submission_artifact_policy_by_id( + task.locked_effective_project_submission_artifact_policy_id or "", + ) + ) + if ( + effective_policy is None + or effective_policy.project_id != task.project_id + or effective_policy.guide_version != task.locked_guide_version + or effective_policy.source_snapshot_id != source_snapshot.id + or effective_policy.source_snapshot_hash != source_snapshot.bundle_hash + or effective_policy.effective_policy_hash + != task.locked_effective_project_submission_artifact_policy_hash + or effective_policy.lifecycle_status not in {"approved", "superseded"} + or canonical_json_hash(effective_policy.effective_policy) + != task.locked_effective_project_submission_artifact_policy_hash + ): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": "locked_effective_project_submission_artifact_policy_hash"}, + ) + + pre_submit_checker_policy = await self._project_repo.get_pre_submit_checker_policy( + task.locked_pre_submit_checker_policy_id or "", + ) + compiled_bundle = ( + pre_submit_checker_policy.compiled_bundle + if pre_submit_checker_policy is not None + else None + ) + if ( + pre_submit_checker_policy is None + or pre_submit_checker_policy.project_id != task.project_id + or pre_submit_checker_policy.guide_version != task.locked_guide_version + or pre_submit_checker_policy.source_snapshot_id != source_snapshot.id + or pre_submit_checker_policy.source_snapshot_hash != source_snapshot.bundle_hash + or pre_submit_checker_policy.effective_policy_id != effective_policy.id + or pre_submit_checker_policy.effective_policy_hash + != effective_policy.effective_policy_hash + or pre_submit_checker_policy.lifecycle_status not in {"compiled", "superseded"} + or pre_submit_checker_policy.compiled_bundle_hash + != task.locked_pre_submit_checker_bundle_hash + or not isinstance(compiled_bundle, dict) + or canonical_json_hash(compiled_bundle) != task.locked_pre_submit_checker_bundle_hash + ): + raise TaskLockedContextInvalid( + "task locked project pre-submit checker policy is invalid", + {"field": "locked_pre_submit_checker_bundle_hash"}, + ) + try: + compiled_checker_names = validate_compiled_pre_submit_checker_bundle( + effective_policy.effective_policy, + effective_policy.effective_policy_hash, + compiled_bundle, + compiler_version=pre_submit_checker_policy.compiler_version, + ) + except PreSubmitCheckerCompilerError as exc: + raise TaskLockedContextInvalid( + "task locked project pre-submit checker policy is invalid", + {"field": "locked_pre_submit_checker_bundle_hash"}, + ) from exc + if list(pre_submit_checker_policy.checker_names or []) != compiled_checker_names: + raise TaskLockedContextInvalid( + "task locked project pre-submit checker projection is invalid", + {"field": "locked_pre_submit_checker_policy_id"}, + ) + + post_submit_checker_policy = ( + await self._project_repo.get_post_submit_checker_policy_by_id( + task.locked_post_submit_checker_policy_id or "", + ) + ) + if ( + post_submit_checker_policy is None + or post_submit_checker_policy.project_id != task.project_id + or post_submit_checker_policy.guide_version + != task.locked_post_submit_checker_policy_version + or post_submit_checker_policy.guide_version != task.locked_guide_version + or post_submit_checker_policy.policy_hash + != task.locked_post_submit_checker_policy_hash + ): + raise TaskLockedContextInvalid( + "task locked post-submit checker policy is invalid", + {"field": "locked_post_submit_checker_policy_hash"}, + ) + try: + parsed_post_submit_body = parse_locked_post_submit_checker_policy_body( + task.locked_post_submit_checker_policy_body, + project_id=task.project_id, + guide_version=task.locked_post_submit_checker_policy_version or "", + policy_hash=task.locked_post_submit_checker_policy_hash or "", + ) + except ValueError as exc: + raise TaskLockedContextInvalid( + "task locked post-submit checker policy body is invalid", + {"field": "locked_post_submit_checker_policy_body"}, + ) from exc + post_submit_summary = PostSubmitPolicyBodySummary( + schema_version=(task.locked_post_submit_checker_policy_body or {}).get( + "schema_version" + ), + default_checkers=parsed_post_submit_body.default_checkers, + required_checkers=parsed_post_submit_body.required_checkers, + warning_checkers=parsed_post_submit_body.warning_checkers, + execution_checkers=parsed_post_submit_body.execution_checkers, + blocking_severities=parsed_post_submit_body.blocking_severities, + ) + + review_policy = await self._project_repo.get_review_policy( + task.project_id, + task.locked_review_policy_version or "", + ) + revision_policy = await self._project_repo.get_revision_policy( + task.project_id, + task.locked_revision_policy_version or "", + ) + payment_policy = await self._project_repo.get_payment_policy( + task.project_id, + task.locked_payment_policy_version or "", + ) + if ( + review_policy is None + or review_policy.guide_version != task.locked_guide_version + or revision_policy is None + or revision_policy.guide_version != task.locked_guide_version + or payment_policy is None + or payment_policy.guide_version != task.locked_guide_version + ): + raise TaskLockedContextInvalid( + "task locked review, revision, or payment policy is invalid", + {"field": "locked_policy_versions"}, + ) + + return LockedTaskContext( + project=project, + guide=guide, + source_snapshot=source_snapshot, + effective_policy=effective_policy, + pre_submit_checker_policy=pre_submit_checker_policy, + post_submit_checker_policy=post_submit_checker_policy, + locked_post_submit_policy_body=post_submit_summary, + review_policy=review_policy, + revision_policy=revision_policy, + payment_policy=payment_policy, + ) + + def _missing_locked_context_fields(self, task: WorkstreamTask) -> list[str]: + """Return missing locked-context fields for a task.""" + return [ + field + for field in LOCKED_CONTEXT_REQUIRED_FIELDS + if not getattr(task, field) + ] + + def _work_context_response( + self, + actor: ActorContext, + task: WorkstreamTask, + context: LockedTaskContext, + ) -> TaskWorkContextResponse: + """Build the worker-safe work-context response.""" + return TaskWorkContextResponse( + task=self._worker_safe_task_response(task), + project=TaskProjectContext( + id=context.project.id, + name=context.project.name, + slug=context.project.slug, + description=context.project.description, + ), + guide=TaskGuideContext( + id=context.guide.id, + version=context.guide.version, + content_markdown=context.guide.content_markdown, + change_summary=context.guide.change_summary, + effective_at=context.guide.effective_at, + ), + review_policy=TaskReviewPolicyContext( + guide_version=task.locked_review_policy_version or "", + ), + revision_policy=TaskRevisionPolicyContext( + guide_version=task.locked_revision_policy_version or "", + ), + payment_policy=TaskPaymentPolicyContext( + guide_version=task.locked_payment_policy_version or "", + base_amount=task.base_amount, + currency=task.currency, + payout_type=task.payout_type, + ), + lifecycle=self._worker_lifecycle_context(actor, task), + ) + + def _submission_requirements_response( + self, + task: WorkstreamTask, + context: LockedTaskContext, + ) -> SubmissionRequirementsResponse: + """Build worker-facing requirements from the locked effective policy.""" + policy = context.effective_policy.effective_policy + if not isinstance(policy, dict): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": "effective_policy"}, + ) + allowed_storage_schemes = self._policy_string_list( + policy, + "allowed_storage_schemes", + ) + required_artifacts = self._policy_list(policy, "required_artifacts") + required_evidence = self._policy_list(policy, "required_evidence") + forbidden_artifacts = self._policy_list(policy, "forbidden_artifacts") + return SubmissionRequirementsResponse( + task_id=task.id, + project_id=task.project_id, + guide_version=task.locked_guide_version or "", + policy_schema_version=self._optional_policy_text(policy, "schema_version"), + merge_algorithm_version=self._optional_policy_text( + policy, + "merge_algorithm_version", + ), + required_packet_fields=self._required_packet_fields(policy), + required_artifacts=[ + RequiredArtifactRequirement( + key=self._policy_rule_text(rule, "key", "required_artifacts"), + path=self._policy_rule_text(rule, "path", "required_artifacts"), + hash_required=self._policy_rule_bool( + rule, + "hash_required", + "required_artifacts", + ), + required=self._policy_rule_bool( + rule, + "required", + "required_artifacts", + ), + description=self._optional_policy_rule_text( + rule, + "description", + "required_artifacts", + ), + ) + for rule in required_artifacts + ], + required_evidence=[ + RequiredEvidenceRequirement( + key=self._policy_rule_text(rule, "key", "required_evidence"), + label=self._policy_rule_text(rule, "label", "required_evidence"), + hash_required=self._policy_rule_bool( + rule, + "hash_required", + "required_evidence", + ), + required=self._policy_rule_bool( + rule, + "required", + "required_evidence", + ), + description=self._optional_policy_rule_text( + rule, + "description", + "required_evidence", + ), + ) + for rule in required_evidence + ], + forbidden_artifacts=[ + ForbiddenArtifactRequirement( + pattern=self._policy_rule_text(rule, "pattern", "forbidden_artifacts"), + reason=self._optional_policy_rule_text( + rule, + "reason", + "forbidden_artifacts", + ), + worker_facing_fix=self._optional_policy_rule_text( + rule, + "worker_facing_fix", + "forbidden_artifacts", + ), + severity=self._optional_policy_rule_text( + rule, + "severity", + "forbidden_artifacts", + ), + ) + for rule in forbidden_artifacts + ], + attestation_terms=self._policy_string_list(policy, "attestation_terms"), + manifest_required=self._policy_bool(policy, "manifest_required"), + artifact_hash_required=self._policy_bool(policy, "artifact_hash_required"), + artifact_hash_algorithm="sha256", + allowed_storage_schemes=allowed_storage_schemes, + storage_reference_rules=StorageReferenceRules( + allowed_storage_schemes=allowed_storage_schemes, + allowed_uri_prefixes=[f"{scheme}://" for scheme in allowed_storage_schemes], + credentials_allowed=False, + query_strings_allowed=False, + fragments_allowed=False, + path_traversal_allowed=False, + ), + maximum_file_size_bytes=self._optional_policy_non_negative_int( + policy, + "maximum_file_size_bytes", + ), + maximum_package_size_bytes=self._optional_policy_non_negative_int( + policy, + "maximum_package_size_bytes", + ), + packaging=self._policy_object(policy, "packaging"), + ) + + def _policy_list(self, policy: dict[str, Any], field: str) -> list[Any]: + """Return a list field from the locked policy or fail closed.""" + value = policy.get(field, []) + if not isinstance(value, list): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": f"effective_policy.{field}"}, + ) + return value + + def _policy_string_list(self, policy: dict[str, Any], field: str) -> list[str]: + """Return a string-list field from the locked policy or fail closed.""" + values = self._policy_list(policy, field) + normalized: list[str] = [] + for value in values: + if not isinstance(value, str) or not value.strip(): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": f"effective_policy.{field}"}, + ) + normalized.append(value.strip()) + return normalized + + def _policy_bool(self, policy: dict[str, Any], field: str) -> bool: + """Return a boolean field from the locked policy or fail closed.""" + value = policy.get(field, True) + if not isinstance(value, bool): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": f"effective_policy.{field}"}, + ) + return value + + def _policy_object(self, policy: dict[str, Any], field: str) -> dict[str, Any]: + """Return an object field from the locked policy or fail closed.""" + value = policy.get(field, {}) + if not isinstance(value, dict): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": f"effective_policy.{field}"}, + ) + return dict(value) + + def _optional_policy_text(self, policy: dict[str, Any], field: str) -> str | None: + """Return an optional text field from the locked policy or fail closed.""" + value = policy.get(field) + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": f"effective_policy.{field}"}, + ) + return value + + def _optional_policy_non_negative_int( + self, + policy: dict[str, Any], + field: str, + ) -> int | None: + """Return an optional non-negative integer from the locked policy.""" + value = policy.get(field) + if value is None: + return None + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": f"effective_policy.{field}"}, + ) + return value + + def _required_packet_fields(self, policy: dict[str, Any]) -> list[str]: + """Return exact submission packet fields required by API and policy.""" + required_fields: list[str] = [] + policy_fields = policy.get("required_packet_fields", []) + if not isinstance(policy_fields, list): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": "effective_policy.required_packet_fields"}, + ) + for field in (*SUBMISSION_CREATE_REQUIRED_PACKET_FIELDS, *policy_fields): + if not isinstance(field, str) or not field.strip(): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": "effective_policy.required_packet_fields"}, + ) + normalized = field.strip() + if normalized not in required_fields: + required_fields.append(normalized) + return required_fields + + def _policy_rule_text(self, rule: object, key: str, collection: str) -> str: + """Return a required text field from a locked policy rule or fail closed.""" + if not isinstance(rule, dict): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": f"effective_policy.{collection}"}, + ) + value = rule.get(key) + if not isinstance(value, str) or not value.strip(): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": f"effective_policy.{collection}.{key}"}, + ) + return value + + def _optional_policy_rule_text( + self, + rule: object, + key: str, + collection: str, + ) -> str | None: + """Return an optional text field from a locked policy rule.""" + if not isinstance(rule, dict): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": f"effective_policy.{collection}"}, + ) + value = rule.get(key) + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": f"effective_policy.{collection}.{key}"}, + ) + return value + + def _policy_rule_bool( + self, + rule: object, + key: str, + collection: str, + ) -> bool: + """Return a boolean field from a locked policy rule or fail closed.""" + if not isinstance(rule, dict): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": f"effective_policy.{collection}"}, + ) + value = rule.get(key, True) + if not isinstance(value, bool): + raise TaskLockedContextInvalid( + "task locked effective project submission artifact policy is invalid", + {"field": f"effective_policy.{collection}.{key}"}, + ) + return value + + def _locked_context_response( + self, + task: WorkstreamTask, + context: LockedTaskContext, + ) -> TaskLockedContextResponse: + """Build the operator-only locked-context response.""" + return TaskLockedContextResponse( + task_id=task.id, + project_id=task.project_id, + locked_guide_version=task.locked_guide_version or "", + locked_guide_source_snapshot_id=task.locked_guide_source_snapshot_id or "", + locked_guide_source_snapshot_hash=task.locked_guide_source_snapshot_hash or "", + locked_effective_project_submission_artifact_policy_id=( + task.locked_effective_project_submission_artifact_policy_id or "" + ), + locked_effective_project_submission_artifact_policy_hash=( + task.locked_effective_project_submission_artifact_policy_hash or "" + ), + locked_pre_submit_checker_policy_id=task.locked_pre_submit_checker_policy_id or "", + locked_pre_submit_checker_bundle_hash=( + task.locked_pre_submit_checker_bundle_hash or "" + ), + locked_post_submit_checker_policy_id=( + task.locked_post_submit_checker_policy_id or "" + ), + locked_post_submit_checker_policy_version=( + task.locked_post_submit_checker_policy_version or "" + ), + locked_post_submit_checker_policy_hash=( + task.locked_post_submit_checker_policy_hash or "" + ), + locked_post_submit_checker_policy_body_summary=( + context.locked_post_submit_policy_body + ), + locked_review_policy_version=task.locked_review_policy_version or "", + locked_revision_policy_version=task.locked_revision_policy_version or "", + locked_payment_policy_version=task.locked_payment_policy_version or "", + ) + + def _worker_safe_task_response(self, task: WorkstreamTask) -> TaskWorkerTaskContext: + """Build a task summary without private source/import provenance.""" + return TaskWorkerTaskContext( + id=task.id, + project_id=task.project_id, + locked_guide_version=task.locked_guide_version or "", + title=task.title, + description=task.description, + task_type=task.task_type, + difficulty=task.difficulty, + skill_tags=list(task.skill_tags), + estimated_time_minutes=task.estimated_time_minutes, + base_amount=task.base_amount, + currency=task.currency, + payout_type=task.payout_type, + status=task.status, + acceptance_criteria=task.acceptance_criteria, + rejection_criteria=task.rejection_criteria, + deadline_at=task.deadline_at, + created_at=task.created_at, + updated_at=task.updated_at, + ) + + def _worker_lifecycle_context( + self, + actor: ActorContext, + task: WorkstreamTask, + ) -> TaskWorkerLifecycleContext: + """Build worker-facing lifecycle booleans and next actions.""" + assigned_to_current_actor = task.assigned_to == actor.actor_id + can_submit = assigned_to_current_actor and task.status in { + TASK_STATUS_IN_PROGRESS, + TASK_STATUS_NEEDS_REVISION, + } + next_actions: list[str] = [] + if task.status == TASK_STATUS_READY and task.assigned_to is None: + next_actions.append("claim") + elif task.status == TASK_STATUS_CLAIMED and assigned_to_current_actor: + next_actions.append("start") + elif can_submit: + next_actions.extend(["run_pre_submit_check", "submit"]) + return TaskWorkerLifecycleContext( + status=task.status, + assigned_to_current_actor=assigned_to_current_actor, + can_run_pre_submit_check=can_submit, + can_submit=can_submit, + next_actions=next_actions, + ) + def _validate_task_contract_fields(self, task: WorkstreamTask) -> None: """Validate task source and reviewability fields before screening. @@ -992,26 +1735,7 @@ def _ensure_locked_context(self, task: WorkstreamTask) -> None: Raises: TaskTransitionBlocked: If any context field is missing. """ - missing = [ - field - for field in ( - "locked_guide_version", - "locked_post_submit_checker_policy_id", - "locked_post_submit_checker_policy_version", - "locked_post_submit_checker_policy_hash", - "locked_post_submit_checker_policy_body", - "locked_review_policy_version", - "locked_revision_policy_version", - "locked_payment_policy_version", - "locked_guide_source_snapshot_id", - "locked_guide_source_snapshot_hash", - "locked_effective_project_submission_artifact_policy_id", - "locked_effective_project_submission_artifact_policy_hash", - "locked_pre_submit_checker_policy_id", - "locked_pre_submit_checker_bundle_hash", - ) - if not getattr(task, field) - ] + missing = self._missing_locked_context_fields(task) if missing: raise TaskTransitionBlocked(f"task missing locked context: {', '.join(missing)}") @@ -1195,6 +1919,12 @@ def _task_response(self, actor: ActorContext, task: WorkstreamTask) -> TaskRespo """ response = TaskResponse.model_validate(task) if not set(actor.roles).intersection(PROJECT_OPERATOR_ROLES): + response.source_ref = None + response.source_payload_hash = None + response.import_batch_id = None + response.external_task_id = None + response.created_by = None + response.assigned_to = None response.locked_guide_source_snapshot_id = None response.locked_guide_source_snapshot_hash = None response.locked_effective_project_submission_artifact_policy_id = None diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index d543a33..ba4c591 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -884,6 +884,90 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert worker_profile["status"] == "active" assert set(worker_profile["skill_tags"]) == {"stem", "proofs"} await request_json(client, "GET", f"/api/v1/tasks/{task['id']}", worker_token) + ready_work_context = await request_json( + client, + "GET", + f"/api/v1/tasks/{task['id']}/work-context", + worker_token, + ) + ensure( + ready_work_context["guide"]["version"] == "v1", + "worker work context did not use locked guide v1", + ) + ensure( + ready_work_context["lifecycle"]["next_actions"] == ["claim"], + "ready worker context did not expose claim as next action", + ) + ensure( + "locked_guide_source_snapshot_hash" + not in json.dumps(ready_work_context, sort_keys=True), + "worker work context leaked source snapshot hash", + ) + for private_field in ( + "source_ref", + "source_payload_hash", + "import_batch_id", + "external_task_id", + "created_by", + "assigned_to", + ): + ensure( + private_field not in ready_work_context["task"], + f"worker work context leaked {private_field}", + ) + submission_requirements = await request_json( + client, + "GET", + f"/api/v1/tasks/{task['id']}/submission-requirements", + worker_token, + ) + ensure( + submission_requirements["required_artifacts"][0]["path"] == "answer.md", + "submission requirements did not expose the locked artifact path", + ) + ensure( + submission_requirements["required_evidence"][0]["key"] == "checker_log", + "submission requirements did not expose the locked evidence key", + ) + ensure( + submission_requirements["artifact_hash_algorithm"] == "sha256", + "submission requirements did not expose platform hash algorithm", + ) + ensure( + submission_requirements["required_packet_fields"] + == [ + "summary", + "package_hash", + "artifact_hash_manifest", + "worker_attestation", + ], + "submission requirements did not expose exact submission request fields", + ) + ensure( + "compiled_bundle" not in json.dumps(submission_requirements, sort_keys=True), + "submission requirements leaked compiled checker bundle", + ) + await request_json( + client, + "GET", + f"/api/v1/tasks/{task['id']}/locked-context", + worker_token, + expected_status=403, + ) + locked_context = await request_json( + client, + "GET", + f"/api/v1/tasks/{task['id']}/locked-context", + manager_token, + ) + ensure( + locked_context["locked_guide_source_snapshot_hash"].startswith("sha256:"), + "operator locked context omitted source snapshot hash", + ) + ensure( + locked_context["locked_pre_submit_checker_bundle_hash"].startswith("sha256:"), + "operator locked context omitted pre-submit checker hash", + ) await request_json( client, "GET", @@ -912,6 +996,16 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: worker_token, {"reason": "real worker start"}, ) + active_work_context = await request_json( + client, + "GET", + f"/api/v1/tasks/{task['id']}/work-context", + worker_token, + ) + ensure( + active_work_context["lifecycle"]["can_submit"] is True, + "in-progress worker context did not expose submit readiness", + ) submission = await request_json( client, "POST", diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 10e55cc..5b6a31b 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -2,6 +2,7 @@ import asyncio import hashlib +import json from collections.abc import AsyncIterator, Iterator from datetime import UTC, datetime from decimal import Decimal @@ -29,8 +30,12 @@ from app.modules.actors.service import ActorService from app.modules.projects.models import ( EffectiveProjectSubmissionArtifactPolicy, + GuideSourceSnapshot, + PaymentPolicy, PostSubmitCheckerPolicy, PreSubmitCheckerPolicy, + ReviewPolicy, + RevisionPolicy, SubmissionArtifactPolicy, ) from app.modules.tasks.lifecycle import InvalidTaskTransition, ensure_allowed_transition @@ -255,6 +260,7 @@ async def create_policy_bundle_for_guide( client: AsyncClient, project_id: str, guide_id: str, + policy_body: dict | None = None, ) -> dict: snapshot_response = await client.post( f"/api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots", @@ -292,7 +298,7 @@ async def create_policy_bundle_for_guide( json={ "source_snapshot_id": snapshot["id"], "policy_version": "v1", - "policy_body": policy_body_for_task_tests(), + "policy_body": policy_body or policy_body_for_task_tests(), }, ) assert policy_response.status_code == 201, policy_response.text @@ -614,6 +620,19 @@ def test_submission_create_openapi_documents_domain_error() -> None: assert "details" in domain_schema["properties"] +def test_task_context_openapi_documents_locked_context_domain_error() -> None: + schema = create_app().openapi() + responses = schema["paths"]["/api/v1/tasks/{task_id}/work-context"]["get"][ + "responses" + ] + response_422 = responses["422"]["content"]["application/json"]["schema"] + + assert {"$ref": "#/components/schemas/HTTPValidationError"} in response_422["oneOf"] + domain_schema = next(option for option in response_422["oneOf"] if "properties" in option) + assert domain_schema["properties"]["code"]["enum"] == ["task_locked_context_invalid"] + assert "details" in domain_schema["properties"] + + def test_task_locked_context_constraints_bind_task_submission_and_hashes() -> None: expected_task_constraints = { "fk_workstream_tasks_locked_source_snapshot_hash": [ @@ -1080,7 +1099,10 @@ async def test_worker_task_response_redacts_locked_policy_hashes( monkeypatch: pytest.MonkeyPatch, ) -> None: project = await create_active_project(task_client) - ready_task = await create_ready_task(task_client, project["id"]) + payload = complete_task_payload() + payload["import_batch_id"] = "private-import-batch" + payload["external_task_id"] = "private-external-task" + ready_task = await create_ready_task(task_client, project["id"], payload=payload) operator_response = await task_client.get( f"/api/v1/tasks/{ready_task['id']}", headers=auth_headers(), @@ -1110,10 +1132,430 @@ async def test_worker_task_response_redacts_locked_policy_hashes( "locked_post_submit_checker_policy_version", "locked_post_submit_checker_policy_hash", "locked_post_submit_checker_policy_body", + "source_ref", + "source_payload_hash", + "import_batch_id", + "external_task_id", + "created_by", + "assigned_to", ): assert internal_field not in body +async def test_task_context_apis_return_worker_requirements_and_operator_provenance( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = await create_active_project(task_client) + payload = complete_task_payload() + payload["import_batch_id"] = "private-import-batch" + payload["external_task_id"] = "private-external-task" + started_task = await create_started_task(task_client, project["id"], monkeypatch, payload=payload) + + work_context = await task_client.get( + f"/api/v1/tasks/{started_task['id']}/work-context", + headers=auth_headers(), + ) + assert work_context.status_code == 200, work_context.text + work_body = work_context.json() + assert work_body["task"]["locked_guide_version"] == "v1" + assert work_body["guide"]["version"] == "v1" + assert "# Task Guide v1" in work_body["guide"]["content_markdown"] + assert work_body["payment_policy"]["base_amount"] == "25.00" + assert work_body["lifecycle"]["can_submit"] is True + worker_context_json = json.dumps(work_body, sort_keys=True) + for internal_field in ( + "locked_guide_source_snapshot_hash", + "locked_effective_project_submission_artifact_policy_hash", + "locked_pre_submit_checker_bundle_hash", + "compiled_bundle", + "checker_configs", + ): + assert internal_field not in worker_context_json + for private_field in ( + "source_ref", + "source_payload_hash", + "import_batch_id", + "external_task_id", + "created_by", + "assigned_to", + ): + assert private_field not in work_body["task"] + + requirements = await task_client.get( + f"/api/v1/tasks/{started_task['id']}/submission-requirements", + headers=auth_headers(), + ) + assert requirements.status_code == 200, requirements.text + requirements_body = requirements.json() + assert requirements_body["guide_version"] == "v1" + assert requirements_body["required_packet_fields"] == [ + "summary", + "package_hash", + "artifact_hash_manifest", + "worker_attestation", + ] + assert requirements_body["required_artifacts"] == [ + { + "key": "answer", + "path": "answer.md", + "hash_required": True, + "required": True, + "description": "Main task answer.", + } + ] + assert requirements_body["required_evidence"] == [ + { + "key": "checker_log", + "label": "checker log", + "hash_required": True, + "required": True, + "description": "Evidence used by the reviewer.", + } + ] + assert requirements_body["artifact_hash_algorithm"] == "sha256" + assert set(requirements_body["allowed_storage_schemes"]) == {"local", "s3", "r2"} + assert requirements_body["storage_reference_rules"]["credentials_allowed"] is False + assert requirements_body["storage_reference_rules"]["query_strings_allowed"] is False + requirements_json = json.dumps(requirements_body, sort_keys=True) + for internal_field in ( + "source_snapshot_hash", + "compiled_bundle", + "checker_configs", + "celery", + ): + assert internal_field not in requirements_json + assert "source" not in requirements_body["forbidden_artifacts"][0] + + worker_locked_context = await task_client.get( + f"/api/v1/tasks/{started_task['id']}/locked-context", + headers=auth_headers(), + ) + assert worker_locked_context.status_code == 403 + + set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") + locked_context = await task_client.get( + f"/api/v1/tasks/{started_task['id']}/locked-context", + headers=auth_headers(), + ) + assert locked_context.status_code == 200, locked_context.text + locked_body = locked_context.json() + assert locked_body["locked_guide_version"] == "v1" + assert locked_body["locked_guide_source_snapshot_hash"].startswith("sha256:") + assert locked_body[ + "locked_effective_project_submission_artifact_policy_hash" + ].startswith("sha256:") + assert locked_body["locked_pre_submit_checker_bundle_hash"].startswith("sha256:") + assert locked_body["locked_post_submit_checker_policy_hash"].startswith("sha256:") + assert locked_body["locked_post_submit_checker_policy_body_summary"][ + "required_checkers" + ] == ["check_policy_context_present"] + + +async def test_ready_worker_work_context_omits_private_task_source_fields( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = await create_active_project(task_client) + payload = complete_task_payload() + payload["import_batch_id"] = "ready-private-import" + payload["external_task_id"] = "ready-private-external" + ready_task = await create_ready_task(task_client, project["id"], payload) + await seed_worker_profile("worker-one") + set_dev_actor(monkeypatch, roles="worker", subject="worker-one") + + response = await task_client.get( + f"/api/v1/tasks/{ready_task['id']}/work-context", + headers=auth_headers(), + ) + + assert response.status_code == 200, response.text + body = response.json() + assert body["lifecycle"]["next_actions"] == ["claim"] + for private_field in ( + "source_ref", + "source_payload_hash", + "import_batch_id", + "external_task_id", + "created_by", + "assigned_to", + ): + assert private_field not in body["task"] + + +async def test_work_context_uses_stamped_policy_values_after_same_version_policy_mutation( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = await create_active_project(task_client) + started_task = await create_started_task(task_client, project["id"], monkeypatch) + before_response = await task_client.get( + f"/api/v1/tasks/{started_task['id']}/work-context", + headers=auth_headers(), + ) + assert before_response.status_code == 200, before_response.text + before = before_response.json() + + async with db_session.get_session_factory()() as session: + review_policy = await session.scalar( + select(ReviewPolicy).where( + ReviewPolicy.project_id == project["id"], + ReviewPolicy.guide_version == "v1", + ) + ) + revision_policy = await session.scalar( + select(RevisionPolicy).where( + RevisionPolicy.project_id == project["id"], + RevisionPolicy.guide_version == "v1", + ) + ) + payment_policy = await session.scalar( + select(PaymentPolicy).where( + PaymentPolicy.project_id == project["id"], + PaymentPolicy.guide_version == "v1", + ) + ) + assert review_policy is not None + assert revision_policy is not None + assert payment_policy is not None + review_policy.requires_second_review = True + review_policy.allowed_decisions = ["reject"] + revision_policy.max_revision_rounds = 1 + revision_policy.revision_deadline_hours = 1 + payment_policy.base_amount = Decimal("999.00") + payment_policy.currency = "EUR" + payment_policy.payout_type = "manual" + await session.commit() + + after_response = await task_client.get( + f"/api/v1/tasks/{started_task['id']}/work-context", + headers=auth_headers(), + ) + + assert after_response.status_code == 200, after_response.text + after = after_response.json() + assert after["review_policy"] == before["review_policy"] + assert after["revision_policy"] == before["revision_policy"] + assert after["payment_policy"] == before["payment_policy"] + assert after["payment_policy"]["base_amount"] == "25.00" + assert after["payment_policy"]["currency"] == "USD" + assert after["payment_policy"]["payout_type"] == "fixed" + + +async def test_task_context_apis_fail_closed_when_locked_context_is_missing( + task_client: AsyncClient, +) -> None: + project = await create_active_project(task_client) + task = await create_draft_task(task_client, project["id"]) + + response = await task_client.get( + f"/api/v1/tasks/{task['id']}/submission-requirements", + headers=auth_headers(), + ) + + assert response.status_code == 422 + assert response.json()["code"] == "task_locked_context_invalid" + assert "locked_guide_version" in response.json()["details"]["missing_fields"] + + +@pytest.mark.parametrize( + "mutation", + [ + "source_snapshot_manifest", + "effective_policy_body", + "pre_submit_bundle", + "post_submit_body", + ], +) +async def test_task_context_apis_fail_closed_on_stale_locked_context_rows( + task_client: AsyncClient, + mutation: str, +) -> None: + project = await create_active_project(task_client) + ready_task = await create_ready_task(task_client, project["id"]) + + async with db_session.get_session_factory()() as session: + persisted_task = await session.get(WorkstreamTask, ready_task["id"]) + assert persisted_task is not None + if mutation == "source_snapshot_manifest": + snapshot = await session.get( + GuideSourceSnapshot, + persisted_task.locked_guide_source_snapshot_id, + ) + assert snapshot is not None + snapshot.manifest_json = {**snapshot.manifest_json, "tampered": True} + elif mutation == "effective_policy_body": + effective_policy = await session.get( + EffectiveProjectSubmissionArtifactPolicy, + persisted_task.locked_effective_project_submission_artifact_policy_id, + ) + assert effective_policy is not None + effective_policy.effective_policy = { + **effective_policy.effective_policy, + "required_artifacts": [], + } + elif mutation == "pre_submit_bundle": + pre_submit_policy = await session.get( + PreSubmitCheckerPolicy, + persisted_task.locked_pre_submit_checker_policy_id, + ) + assert pre_submit_policy is not None + pre_submit_policy.compiled_bundle = { + **pre_submit_policy.compiled_bundle, + "rules": [], + } + else: + persisted_task.locked_post_submit_checker_policy_body = { + **persisted_task.locked_post_submit_checker_policy_body, + "blocking_severities": [], + } + await session.commit() + + response = await task_client.get( + f"/api/v1/tasks/{ready_task['id']}/work-context", + headers=auth_headers(), + ) + + assert response.status_code == 422 + assert response.json()["code"] == "task_locked_context_invalid" + + +async def test_submission_requirements_fail_closed_on_hash_consistent_malformed_policy_shape( + task_client: AsyncClient, +) -> None: + project = await create_active_project(task_client) + async with db_session.get_session_factory()() as session: + effective_policy = await session.scalar( + select(EffectiveProjectSubmissionArtifactPolicy).where( + EffectiveProjectSubmissionArtifactPolicy.project_id == project["id"], + EffectiveProjectSubmissionArtifactPolicy.guide_version == "v1", + EffectiveProjectSubmissionArtifactPolicy.lifecycle_status == "approved", + ) + ) + assert effective_policy is not None + pre_submit_policy = await session.scalar( + select(PreSubmitCheckerPolicy).where( + PreSubmitCheckerPolicy.effective_policy_id == effective_policy.id, + PreSubmitCheckerPolicy.lifecycle_status == "compiled", + ) + ) + assert pre_submit_policy is not None + replacement_checker_names = list(pre_submit_policy.checker_names) + replacement_checker_configs = dict(pre_submit_policy.checker_configs) + compiler_version = pre_submit_policy.compiler_version + original_compiled_bundle = dict(pre_submit_policy.compiled_bundle) + await session.delete(pre_submit_policy) + await session.flush() + + malformed_policy = { + **effective_policy.effective_policy, + "schema_version": ["not", "a", "string"], + } + malformed_policy_hash = canonical_json_hash(malformed_policy) + malformed_bundle = { + **original_compiled_bundle, + "effective_policy_hash": malformed_policy_hash, + } + malformed_bundle_hash = canonical_json_hash(malformed_bundle) + + effective_policy.effective_policy = malformed_policy + effective_policy.effective_policy_hash = malformed_policy_hash + session.add( + PreSubmitCheckerPolicy( + id=str(uuid4()), + project_id=effective_policy.project_id, + guide_id=effective_policy.guide_id, + guide_version=effective_policy.guide_version, + source_snapshot_id=effective_policy.source_snapshot_id, + source_snapshot_hash=effective_policy.source_snapshot_hash, + effective_policy_id=effective_policy.id, + effective_policy_hash=malformed_policy_hash, + lifecycle_status="compiled", + compiler_version=compiler_version, + compiled_bundle=malformed_bundle, + compiled_bundle_hash=malformed_bundle_hash, + checker_names=replacement_checker_names, + checker_configs=replacement_checker_configs, + created_by="test", + ) + ) + await session.commit() + + ready_task = await create_ready_task(task_client, project["id"]) + response = await task_client.get( + f"/api/v1/tasks/{ready_task['id']}/submission-requirements", + headers=auth_headers(), + ) + + assert response.status_code == 422 + body = response.json() + assert body["code"] == "task_locked_context_invalid" + assert body["details"]["field"] == "effective_policy.schema_version" + + +async def test_task_context_apis_use_v1_locked_requirements_after_v2_activation( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = await create_active_project(task_client) + started_task = await create_started_task(task_client, project["id"], monkeypatch) + + v1_requirements = await task_client.get( + f"/api/v1/tasks/{started_task['id']}/submission-requirements", + headers=auth_headers(), + ) + assert v1_requirements.status_code == 200, v1_requirements.text + + set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") + guide_v2 = await task_client.post( + f"/api/v1/projects/{project['id']}/guides", + headers=auth_headers(), + json=complete_guide_payload("v2"), + ) + assert guide_v2.status_code == 201, guide_v2.text + policy_v2 = policy_body_for_task_tests() + policy_v2["required_artifacts"] = [ + { + "key": "v2_answer", + "path": "v2-answer.md", + "hash_required": True, + "required": True, + "description": "New v2 artifact.", + } + ] + await create_policy_bundle_for_guide( + task_client, + project["id"], + guide_v2.json()["id"], + policy_v2, + ) + activate_v2 = await task_client.post( + f"/api/v1/projects/{project['id']}/guides/{guide_v2.json()['id']}/activate", + headers=auth_headers(), + ) + assert activate_v2.status_code == 200, activate_v2.text + assert activate_v2.json()["guide"]["version"] == "v2" + + set_dev_actor(monkeypatch, roles="worker", subject="worker-one") + work_context = await task_client.get( + f"/api/v1/tasks/{started_task['id']}/work-context", + headers=auth_headers(), + ) + requirements = await task_client.get( + f"/api/v1/tasks/{started_task['id']}/submission-requirements", + headers=auth_headers(), + ) + + assert work_context.status_code == 200, work_context.text + assert requirements.status_code == 200, requirements.text + assert work_context.json()["guide"]["version"] == "v1" + assert requirements.json()["guide_version"] == "v1" + assert requirements.json()["required_artifacts"] == v1_requirements.json()[ + "required_artifacts" + ] + assert requirements.json()["required_artifacts"][0]["path"] == "answer.md" + + async def test_tasks_under_same_active_guide_share_project_pre_submit_checker( task_client: AsyncClient, ) -> None: @@ -1770,7 +2212,17 @@ async def test_assigned_worker_submits_v1_and_task_moves_to_submitted( task = await task_client.get(f"/api/v1/tasks/{started_task['id']}", headers=auth_headers()) assert task.status_code == 200, task.text - assert task.json()["status"] == "submitted" + task_body = task.json() + assert task_body["status"] == "submitted" + for internal_field in ( + "source_ref", + "source_payload_hash", + "import_batch_id", + "external_task_id", + "created_by", + "assigned_to", + ): + assert internal_field not in task_body audit = await task_client.get( f"/api/v1/tasks/{started_task['id']}/audit-events", diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index ac4351e..5941dab 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -613,6 +613,14 @@ the work is split into another project/guide. The task stores `PreSubmitCheckerPolicy.compiled_bundle_hash`; it does not own a newly derived policy or newly compiled checker. +Task context APIs read this already-stamped context. `work-context` and +`submission-requirements` return task-visible worker-safe guide and requirement +projections from the locked rows. `locked-context` is available only to +token-authenticated `admin` and `project_manager` actors and exposes the full +locked source snapshot, effective policy, pre-submit checker, post-submit +checker, review, revision, and payment provenance. None of these reads +recompute from the current active guide. + Approval creates a project-scoped `PreSubmitCheckerPolicy` row with lifecycle status `compiled`. The trusted compiler writes the immutable `compiled_bundle` JSON and `compiled_bundle_hash` in the same approval path. The compiled bundle diff --git a/docs/architecture_system_architecture.md b/docs/architecture_system_architecture.md index 35653a2..291ba74 100644 --- a/docs/architecture_system_architecture.md +++ b/docs/architecture_system_architecture.md @@ -155,6 +155,9 @@ Owns: - task requirements - task acceptance criteria - queue views +- worker-safe task work context +- worker-safe submission requirements derived from locked effective project policy +- locked task provenance for `admin` and `project_manager` actors ### Submission Service diff --git a/docs/glossary.md b/docs/glossary.md index 1578d24..64ce3ef 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -116,6 +116,28 @@ The worker-facing domain error code returned when a submission-create attempt is A unit of work inside a project. +## Task Work Context + +The worker-safe API projection of a task's locked guide, project summary, +review policy, revision policy, payment policy, and lifecycle state. It is read +from the task's stamped locked context and does not expose source snapshot +hashes, private source/import refs, compiled checker bundles, checker configs, +Celery ids, or setup errors. + +## Task Submission Requirements + +The worker-safe API projection of the task's locked effective project +submission artifact policy. It tells the worker the exact required artifacts, +evidence keys, forbidden artifact rules, storage reference rules, packaging +rules, hash algorithm, size limits, and attestation terms before submission. + +## Task Locked Context + +The `admin` and `project_manager` API projection of a task's locked guide and +policy provenance, including guide source snapshot id/hash, effective policy +id/hash, pre-submit checker policy id/hash, post-submit checker policy +id/hash/body summary, and review, revision, and payment policy versions. + ## Task Contract The normalized task fields required for Workstream to screen, assign, check, review, pay, and audit work. diff --git a/docs/operations_project_operating_manual.md b/docs/operations_project_operating_manual.md index e817644..f610e43 100644 --- a/docs/operations_project_operating_manual.md +++ b/docs/operations_project_operating_manual.md @@ -84,6 +84,14 @@ submission artifact policy hash, and project pre-submit checker bundle hash. A task cannot move to `READY` until the task contract is complete, the guide version is locked, submission artifact requirements are clear, checker/review/revision/payment policy versions are locked, and a release decision is recorded. +After screening and release, workers use +`GET /api/v1/tasks/{task_id}/work-context` for the locked guide and lifecycle +context and `GET /api/v1/tasks/{task_id}/submission-requirements` for the exact +artifact, evidence, storage, packaging, hash, and attestation requirements. +Actors with the `admin` or `project_manager` role use +`GET /api/v1/tasks/{task_id}/locked-context` when support or a live API drill +needs full locked provenance without database inspection. + ### Submission Quality Gate A submission cannot move to human review until required checkers run against the exact submission version and artifact hashes. High-severity failures return to the worker when submission-caused; platform infrastructure failures remain in checker retry handling or audited admin/project manager intervention. diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 67ddff2..426b2cd 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -44,6 +44,7 @@ Current phase: Week 3 review and revision preparation. - Chunk 10 checker trial with the expanded real API sample matrix, failure catalog, false-positive notes, missing-checker notes, and internal verifier evidence. - Week 2 real HTTP API drill through Flow-token auth, project/guide/task/submission lifecycle, pre-submit checks, automatic checker runs, `pre_submission_checker_failed` intake failures, worker redaction, internal `task_setup_blocked`, and trusted checker retry. - Chunk 11 actor identity/profile registry for verified Flow actors. +- Chunk 12 project setup-run and project policy visibility APIs for setup runs, sufficiency reports, submission artifact policies, effective policy, and compiled project pre-submit checker policy. ## Review Tracks Closed @@ -63,11 +64,9 @@ Current phase: Week 3 review and revision preparation. - Week 3 must keep review decisions canonical: `accept`, `needs_revision`, and `reject`. - `needs_revision` from human review must carry `outcome_source = human_review` and a review decision id; checker-caused `needs_revision` keeps `outcome_source = auto_checker`. - Review findings, revision replay, and reviewer-quality metrics are the next backend contracts to lock. -- Chunk 12 project setup-run and project policy visibility APIs are implemented in open PR #76 and under human review. -- Before the next Terminal Benchmark drill, operators need HTTP visibility for - setup runs, sufficiency reports, submission artifact policies, effective - policy, and compiled project pre-submit checker policy instead of direct DB - inspection. +- Chunk 13 task context APIs are in progress to expose worker-safe work + context, exact submission requirements, and operator-only locked provenance + before rerunning the Terminal Benchmark drill. ## Pending Before Pilot @@ -84,9 +83,9 @@ Run from the backend directory against local Postgres: 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 project policy visibility plus the following flow: +The script runs migrations forward and exercises project policy visibility plus task context APIs across the following flow: -`Project -> Guide -> Task -> Screening -> Ready -> Claim -> Start -> Submit -> Lock submission` +`Project -> Guide -> Task -> Screening -> Ready -> Work context -> Submission requirements -> Locked context -> Claim -> Start -> Submit -> Lock submission` ## Week 2 Real API Drill