diff --git a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json index 74066a553..40893e54a 100644 --- a/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json +++ b/.ci/auth-boundaries/TEST_STRUCTURE_DEBT.json @@ -134,11 +134,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "09de018e697bb624bf2a306c11ddbceda59af6a63519b8052eb8237ceb5fcfc4", - "end_line": 3147, + "content_sha256": "cbb443b850ec3d7bcd123a3730c07159f6a39f79c3e1464a5e6ba110775d2f3d", + "end_line": 3123, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 3147, + "observed_lines": 3123, "path": "backend/tests/test_artifact_admission.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -146,11 +146,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "5ae46dbafad129c7c2527b2dda6deecae72e48d2e9f179d1e6fc17f31360053d", - "end_line": 3865, + "content_sha256": "c727fa8af298612bb35982d0728c0604fd209d6ab3e9b75b4c1978ded91addd0", + "end_line": 3859, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 3865, + "observed_lines": 3859, "path": "backend/tests/test_auth.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -170,11 +170,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "3c32f594c72e730313f6fc7e846d97173fb1ae31f460173bf16c0f2265a0f8be", - "end_line": 1698, + "content_sha256": "1d02c65c3dd412889c04f158558f8c0de5f6285f2b0f1d317a26535ce7580bea", + "end_line": 1696, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 1698, + "observed_lines": 1696, "path": "backend/tests/test_default_pre_submit_execution.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -182,11 +182,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "07e71b1966c8cdfad094086cc8b98cc0d6bb1d0bd720e0a14605b1c8a9aa34ad", - "end_line": 7007, + "content_sha256": "61815fcfad0549690c8f5e4f83ead8bbb599bed4e0eab9c8964315d8dcde2a2f", + "end_line": 6702, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 7007, + "observed_lines": 6702, "path": "backend/tests/test_projects.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -195,62 +195,62 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "0febc9420a1ee5b520e320ba511cda9f73c6469d1a63bf537a263ba5886f3ce3", - "end_line": 3105, + "end_line": 3081, "hard_limit": 120, "kind": "test_function", "observed_lines": 123, "path": "backend/tests/test_artifact_admission.py", "qualified_symbol": "test_checker_output_put_observation_terminal_outcomes", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2983 + "start_line": 2959 }, { "capability": "unassigned_legacy_auth", "content_sha256": "fa7c9ad2acfa3b9810c43509966c61171d2234042ca32f7cb8e2d543f17cc252", - "end_line": 2890, + "end_line": 2866, "hard_limit": 120, "kind": "test_function", "observed_lines": 187, "path": "backend/tests/test_artifact_admission.py", "qualified_symbol": "test_checker_output_requires_exact_active_fixed_service_identity", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2704 + "start_line": 2680 }, { "capability": "unassigned_legacy_auth", "content_sha256": "e9f4d7d65b3794e1642401465a6f2c5a4ae179865c1a4113f97b8494ae0e1241", - "end_line": 2570, + "end_line": 2546, "hard_limit": 120, "kind": "test_function", "observed_lines": 199, "path": "backend/tests/test_artifact_admission.py", "qualified_symbol": "test_guide_admission_consumes_real_project_manager_prep_atomically", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2372 + "start_line": 2348 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "379ac4f3a04d2c16de034ce53b048dbafe6b1f57f6357fe1589ca736adaf9d22", - "end_line": 2369, + "content_sha256": "76e5cd8871311fb09405aa4c2fb280ff8722be235764e4eb3c6907f83c31fb2c", + "end_line": 2345, "hard_limit": 120, "kind": "test_function", - "observed_lines": 164, + "observed_lines": 157, "path": "backend/tests/test_artifact_admission.py", "qualified_symbol": "test_guide_admission_derives_three_scopes_without_provider_evidence", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2206 + "start_line": 2189 }, { "capability": "unassigned_legacy_auth", "content_sha256": "8ea3370c1579f6b6a29a0537941fe57957e691d76ba4a781638c45b62e3ffffa", - "end_line": 1598, + "end_line": 1581, "hard_limit": 120, "kind": "test_function", "observed_lines": 123, "path": "backend/tests/test_artifact_admission.py", "qualified_symbol": "test_verification_claim_takeover_and_scanner_due_order_are_fenced", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1476 + "start_line": 1459 }, { "capability": "unassigned_legacy_auth", @@ -303,74 +303,74 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "9df08af6d7d3193c9112ac885fbe242b0f6ef072dfe9536f91980a61d14a7b4c", - "end_line": 3845, + "end_line": 3838, "hard_limit": 120, "kind": "test_function", "observed_lines": 879, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_identity_link_lifecycle_real_postgres_concurrency", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2967 + "start_line": 2960 }, { "capability": "unassigned_legacy_auth", "content_sha256": "15c7147c11fa1802f43480b38a3678ac1a2586320ae2f0013622f1d63b2a3806", - "end_line": 2522, + "end_line": 2515, "hard_limit": 120, "kind": "test_function", "observed_lines": 535, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_identity_link_lifecycle_real_postgres_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1988 + "start_line": 1981 }, { "capability": "unassigned_legacy_auth", "content_sha256": "188db6b76b3f6dbe4077dbbb724bc9fe7529058b099cc7409bc5a443686a096a", - "end_line": 2964, + "end_line": 2957, "hard_limit": 120, "kind": "test_function", "observed_lines": 440, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_profile_lifecycle_real_postgres_concurrency", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2525 + "start_line": 2518 }, { "capability": "unassigned_legacy_auth", "content_sha256": "eca9eeff229ee37c103188f9a31e278773a69e96f2fd354662723f840cf8f16a", - "end_line": 1985, + "end_line": 1978, "hard_limit": 120, "kind": "test_function", "observed_lines": 482, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_actor_profile_lifecycle_real_postgres_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1504 + "start_line": 1497 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "8ea087b906726103295a019967f8c38e42b55deb4e912fda3a41289fda23f35d", - "end_line": 1021, + "content_sha256": "8d3b64ef2e322ce21f5709350b61cf84bca0afef6ef67b504f2757db9852f1f5", + "end_line": 1014, "hard_limit": 120, "kind": "test_function", - "observed_lines": 647, + "observed_lines": 646, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_controlled_service_actor_provisioning_includes_project_setup_and_is_atomic", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 375 + "start_line": 369 }, { "capability": "unassigned_legacy_auth", "content_sha256": "d26732dd125f8c2062b5a4b45b59fbccd283ee04939a9da7be1576235524292f", - "end_line": 1432, + "end_line": 1425, "hard_limit": 120, "kind": "test_function", "observed_lines": 409, "path": "backend/tests/test_auth.py", "qualified_symbol": "test_service_actor_provisioning_failure_and_authority_races_are_atomic", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1024 + "start_line": 1017 }, { "capability": "unassigned_legacy_auth", @@ -578,11 +578,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "67205f657052435d648c97af78f813a029de1ecb7fb3f7aacd4d20fe19980e2e", - "end_line": 1112, + "content_sha256": "6009a0d15bb9b1de11c8bff066e79b9aa2c3f12f18791e034bee4da5339c0afb", + "end_line": 1110, "hard_limit": 120, "kind": "test_function", - "observed_lines": 678, + "observed_lines": 676, "path": "backend/tests/test_default_pre_submit_execution.py", "qualified_symbol": "test_effective_evidence_workflow_persists_once_and_replays_exactly", "removal_chunk": "WS-AUTH-003-CLOSE", @@ -602,15 +602,15 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "5b90b7eb9a22e2d40d9e8e88eea46275803fdcc8ff6a59c4d83b1de9d875dfe2", - "end_line": 2677, + "content_sha256": "e723bd10cd20ba716fb75edda2a5b0c21a78dfce771efd55154834063c08b4b4", + "end_line": 2470, "hard_limit": 120, "kind": "test_function", - "observed_lines": 138, + "observed_lines": 128, "path": "backend/tests/test_projects.py", "qualified_symbol": "test_guide_mutation_service_executes_all_three_authorized_happy_paths", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2540 + "start_line": 2343 }, { "capability": "unassigned_legacy_auth", @@ -627,26 +627,26 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "10292b6ed6f73baae351efbcb13102da0bd21eea2926587cef48e784baa95803", - "end_line": 397, + "end_line": 408, "hard_limit": 100, "kind": "test_helper", "observed_lines": 104, "path": "backend/tests/project_create_fixtures.py", "qualified_symbol": "seed_authorized_project", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 294 + "start_line": 305 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "894b529b9caab6e0493a0fcebbab284038e8a0da1b839307744918700023f838", - "end_line": 691, + "content_sha256": "b1c6806227df3d6a7739197b79c40dda06599167414e747c2dea90f09bb205bc", + "end_line": 674, "hard_limit": 100, "kind": "test_helper", - "observed_lines": 351, + "observed_lines": 333, "path": "backend/tests/test_artifact_admission.py", "qualified_symbol": "_seed_checker_output_relationships", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 341 + "start_line": 342 }, { "capability": "unassigned_legacy_auth", diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 1a9271682..cd18122cd 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -776,6 +776,10 @@ "group": "lifecycle", "target": "backend/app/modules/projects/create_service.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/projects/document_upload.py" + }, { "group": "lifecycle", "target": "backend/app/modules/projects/guide_compilation/automatic_request.py" @@ -1113,7 +1117,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "5fc2459ede25039ef5e37475ab34860e0f3d9fc5a68ead09ecdea8027317d4b2", + "authority_digest": "8dd1954cb63c958e7826e20353a7f286a0dd60beff434fe6d978c096626514d6", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.commitrail/INDEX.md b/.commitrail/INDEX.md index 295a82a11..49ed416c1 100644 --- a/.commitrail/INDEX.md +++ b/.commitrail/INDEX.md @@ -11,7 +11,7 @@ for current product capability. | [WS-AUTH-001](initiatives/WS-AUTH-001/OVERVIEW.md) | Planned | Unavailable shared-dispatcher contracts after delivered CP05; POL-04B consumes completed finalization authority; AUTH-12F4 follows POL-05A proposal review | | [WS-CON-001](initiatives/WS-CON-001/OVERVIEW.md) | Planned | CP06 selected-policy validation, then guide-activation persistence | | [WS-AUTH-003](initiatives/WS-AUTH-003/OVERVIEW.md) | Planned | Repair touched capabilities through `authorization.api` | -| [WS-POL-003](initiatives/WS-POL-003/OVERVIEW.md) | Planned | Automatic unified setup and separate draft proposals delivered; POL-05A hidden proposal review/correction/approval custody, then AUTH-12F4 and POL-05B approval/manual rerun | +| [WS-POL-003](initiatives/WS-POL-003/OVERVIEW.md) | Planned | Unified setup, separate draft proposals and guide document intake delivered; POL-05A hidden proposal review/correction/approval custody, then AUTH-12F4 and POL-05B approval/manual rerun | | [WS-REV-001](initiatives/WS-REV-001/OVERVIEW.md) | Planned | Shared acceptance/source and existing fence foundations; human hidden review work remains independently dependency-gated | | [WS-QUAL-002](initiatives/WS-QUAL-002/OVERVIEW.md) | Planned | Populate subsystem ownership before changed-line mutation work | | [WS-QUAL-003](initiatives/WS-QUAL-003/OVERVIEW.md) | Planned | Audit and prune test proof, add missing safety cases, decompose oversized test modules | diff --git a/.commitrail/initiatives/WS-POL-003/OVERVIEW.md b/.commitrail/initiatives/WS-POL-003/OVERVIEW.md index 49f6e4d41..22e68ff4b 100644 --- a/.commitrail/initiatives/WS-POL-003/OVERVIEW.md +++ b/.commitrail/initiatives/WS-POL-003/OVERVIEW.md @@ -1,6 +1,7 @@ # WS-POL-003 — Unified project-guide compilation -Latest completed POL behavior: [POL-04B live unified setup](WS-POL-003-04B.md). +Latest completed POL behavior: [POL-04B2 guide document intake](WS-POL-003-04B2.md), +building on [POL-04B live unified setup](WS-POL-003-04B.md). Current remaining design: [POL plan](planning/PLAN.md) and the [cross-owner dependency contract](../WS-ARCH-001/planning/PLAN.md#current-dependency-contract). @@ -10,7 +11,8 @@ Exact pre-cutover work record: [`STATUS.md`](pre-cutover/STATUS.md), - Disposition: Planned - Completed boundary: automatic unified execution, deterministic projections, - immutable setup finalization and current-authority replay. + immutable setup finalization, current-authority replay and one public guide + creation/document-upload flow. - Intent: compile one locked guide and its policies into authoritative, versioned project behavior without circular subsystem authority. - Next usable boundary: POL-05A builds hidden complete-proposal review, correction and pre-policy approval custody, followed by AUTH-12F4 and POL-05B approval/manual rerun. diff --git a/.commitrail/initiatives/WS-POL-003/WS-POL-003-04B2.md b/.commitrail/initiatives/WS-POL-003/WS-POL-003-04B2.md new file mode 100644 index 000000000..0fee0ea32 --- /dev/null +++ b/.commitrail/initiatives/WS-POL-003/WS-POL-003-04B2.md @@ -0,0 +1,239 @@ +# WS-POL-003-04B2 — One guide document intake flow + +- Initiative: WS-POL-003 +- Durable disposition: Complete +- Intended merge outcome: Guide creation declares its documents and task + examples; documented guide/document upload endpoints complete the input and + trigger automatic setup without a public source-snapshot creation step. + +## Intent + +The manager supplies the guide's metadata, at least one task example and the +complete document list together, then uploads those documents. Workstream +stores original bytes in ArtifactStore/S3 and starts setup only when all +declared documents are committed. Internal document-set hashes and lineage +must not become an extra client-managed workflow. + +The user identified this correction after POL-04B merged. It precedes POL-05A; +unverified 05A implementation is preserved on its separate unmerged branch. + +## Starting behavior + +`GuideMutationService.create_guide` creates metadata alone. A second public +`create_snapshot` command declares the documents and creates `awaiting_documents` +setup. The upload route in `projects/router.py` requires snapshot/item IDs and +is excluded from OpenAPI. ART already admits and stores exact original bytes; +its completion callback starts one automatic setup after every declared item +has committed storage custody. Automatic request AUTH binds the internal source +creation operation and consent, so that lineage cannot simply be deleted. + +## Bounded change + +### Allowed + +- PROJECTS guide schemas, guide mutation service/repository/router, guide + document locator/read contracts and affected guide response composition. +- The existing ART guide ingest command/ports and composition where needed to + resolve internal exact document ownership from project/guide/document IDs. +- Affected AUTH guide creation, internal document-set creation and ingest + resource/replay checks; no broader roles or permissions. +- Duplicate `/auth/me` projection, its exclusive response schema and affected + callers/docs/tests, using canonical `/actors/me` for self-read; retain shared + authentication dependencies until their live consumers are replaced. +- Exact structural-debt inventory refresh for shrinking affected tests; no + threshold, exception or boundary-policy changes. Register new regression tests + in their exact hosted semantic owner and update the matching inventory assertion; + register the new lookup module in the exact behavior-ownership partition; + no skip, deselection or coverage reduction. +- Coverage concurrency configuration in `backend/pyproject.toml` for the existing + SQLAlchemy greenlet execution, with a discriminating measurement probe; preserve + every coverage floor, source inventory and exclusion rule. +- Focused API, PostgreSQL, ART/MinIO and AUTH tests; affected test fixtures, + scripts/examples, OpenAPI, current docs and roadmap/navigation. +- Update canonical PostgreSQL mutation custody and add a migration enforcing + paired creation and one immutable document set for future creates; preserve + retained document, policy, task and audit data. + +### Not allowed + +Public source-snapshot creation or an alias/compatibility route, another upload +subsystem, raw guide bodies in PostgreSQL, model/runtime changes, PM approval or +correction implementation, policy/checker activation, CP06, or frontend work. + +## Design + +1. Require a bounded nonempty document list on the standard `ProjectGuideCreate` + contract alongside mandatory task examples. Each public document declaration + contains a bounded label and supported PDF/DOCX/PPTX media type; upload-only + source kind and adapter are server-owned, not client options. +2. Extend the existing guide creation transaction to create its internal exact + document set, item IDs and waiting setup once. Preserve the independently + required internal source-consent authority and automatic-request lineage in + that same transaction. Remove the standalone public snapshot command/schema + and route; no general alternate creation entry remains callable. +3. Return document IDs and metadata with the guide. Clients upload through + `/projects/{project_id}/guides/{guide_id}/documents/{document_id}/content`. + Resolve snapshot membership internally, then reuse existing exact AUTH/ART + admission before consuming any body bytes. Remove the old snapshot/item + upload URL and document binary body, content types, replay key and response + in OpenAPI. Do not create signed unrestricted storage access. +4. Preserve the existing all-documents-ready callback, exact-byte checksum and + storage custody, generation fence and one-attempt automatic execution. A + partial upload set never dispatches setup; replay does not duplicate items, + setup runs or provider attempts. Guide metadata edits cannot change the + immutable declared input; a changed document set belongs to a new guide. +5. Reauthorize exact create/upload replay, reject changed document lists under + reused keys, and roll back guide/set/setup plus authority if any write fails. + Remove obsolete client calls and tests; retain required TASK locked-lineage + and audit readers reached by the affected dependency trace. + +### Exact creation and upload contracts + +- One caller-owned root transaction consumes distinct guide-create and private + source-consent decisions. Reserve and complete both existing mutation ledger + rows, paired by actor, identity link, project and the same external replay key + (actions distinguish the rows). The source row retains its setup ID and the + snapshot/setup retain the original source decision required by automatic origin. +- Exact replay loads both committed rows and their immutable guide, snapshot, + items and initial setup, reconstructs both original resource contexts, and + freshly prepares/consumes both current authorities. Concurrent winner discovery + discards candidate handles and binds the winner's stored identities. Pending, + mismatched or partial pairs fail closed; replay never rewrites original consent. +- PostgreSQL must enforce the exact pair, source/setup ownership and no future + second document set for a guide. Historical retained rows remain readable; + no alternate creation behavior is introduced to service them. Direct SQL must + reject missing halves, cross-paired halves and second document sets. +- Use a create-specific response: guide metadata, public documents and initial + setup ID/status. A document ID is the existing canonical source-item identity; + expose its normalized label, media type and order, without snapshot selectors. + Caller declaration order is authoritative and is preserved in the manifest. + Reordering declarations under a reused replay key is a conflict. +- Upload requires a UUID `Idempotency-Key`, raw binary body and `Content-Type` + exactly matching the immutable PDF/DOCX/PPTX declaration. Reject a header + mismatch before reading bytes. `Content-Length` is untrusted preflight input; + enforce streaming limits aligned with guide access (default 64 MiB per + document), aggregate access and scratch/admission quotas. SHA-256 and byte + count are server-computed; accept no client digest as custody authority. + Retain magic-format verification before admission/provider write. +- Resolve exact creation-set membership and current setup generation inside the + existing ART/AUTH root before body iteration. Public upload responses contain + only document ID, server commitment, status and replay state: no snapshot, + put-attempt, operation, namespace, provider or S3 identifiers. OpenAPI must + declare required binary request bodies for all three media types, replay key, + 202/error schemas and `artifact.guide_source.ingest` action metadata. +- Exact upload replay reuses the same internal attempt/receipt. Different bytes + under the same key, or a different key for the same document, cannot overwrite + content or create another provider object. Concurrent final uploads converge + on one dispatch. A committed final upload whose callback fails is recovered by + `scan_guide_setup_continuations` without repeating storage or inference. +- Remove `guide_mutation_router.create_snapshot`, + `GuideMutationService.create_snapshot`, request schema + `GuideSourceSnapshotCreate`, `projects.router.ingest_guide_source_artifact`, + the old URLs and their exclusive callers, fixtures and tests. Router/OpenAPI, + syntax-aware callable and caller scans must prove removal. Preserve consumed + snapshot read schemas, internal models, audit and TASK locked-history readers. + +### Adjacent API cleanup audit + +The user requested this audit in the current change. These are concrete +dependencies found at the initial audit; the dispositions below incorporate +the merged task-authority cleanup in #397: + +| Surface | Inspected dependency | Required disposition | +|---|---|---| +| `GET /auth/me` | `api/routes/auth.py` exposes the token-derived `ActorResponse`; admission fixtures call it through `get_registered_actor`, which also writes obsolete identity observations. `/actors/me` already owns canonical self-read. | In this PR, remove the duplicate projection and exclusive schema/callers; verify canonical admission effects for affected fixtures. Shared `get_registered_actor` consumers require a separate AUTH/TASK dependency trace before deleting that shared dependency. | +| `POST /workers/me/profile` | `ActorService.activate_legacy_workflow_eligibility` writes eligibility consumed by TASK claim, start, submission and action projections through `LegacyWorkflowEligibilityCompatibility`. | Completed in #397: activation and the eligibility bridge are removed; affected task and submission operations use canonical exact-project authority. Retained data is not deleted. | +| Public guide source creation | Guide metadata currently lacks document declarations; upload requires snapshot/item IDs. | This 04B2 implementation removes the extra command and old upload URL completely. | +| Manual artifact-policy approval | `ProjectService` explicitly requires manual lineage and rejects unified compilation output. | POL-05A owns the already-planned unified review/approval custody replacement and removal of the manual path; public authorization follows AUTH-12F4/POL-05B. Do not advertise current manual approval as unified setup approval. | +| `POST /tasks/{id}/submission-precheck` | `TaskService.create_submission` calls the same JSON checker service before creating a Submission. The ZIP preparation implementation is hidden; canonical data-model documentation assigns public cutover to ARCH-02I. | Completed in #397: the obsolete public JSON precheck is removed and hidden submission creation uses canonical prepared authority and locked intake rules. Public ZIP submission cutover remains separate; do not advertise hidden creation as public. | + +Checker-result/history reads and submission repair serve inspection/recovery; +this audit supplies no evidence that they are redundant. Trace any actual shared +consumer reached by replacement, but do not delete retained evidence or working +recovery merely because it is older. The current PR must disclose remaining +surfaces and their owners; it must not claim repository-wide API cleanup. + +## Acceptance criteria + +- One guide create call returns its exact ordered documents and waiting setup; + no client snapshot API or ID is required to upload. +- Missing/empty documents or task examples and unsupported formats reject. +- Multi-document upload proves no dispatch after a proper subset, then one + dispatch after all committed documents; exact replay produces no duplicates. +- Wrong project/guide/document, revoked authority, stale generation, malformed + replay key and changed create payload deny without reading body or writes. +- Real PostgreSQL proves creation atomicity, concurrent exact replay and source + consent/automatic-request validity with all triggers enabled. +- The same ART/MinIO path stores exact bytes with bounded access and cleanup. +- OpenAPI documents the real binary upload route and complete create schema; + obsolete snapshot creation/upload routes are absent, not merely hidden. +- Integrated ASGI/PostgreSQL/MinIO proof exercises partial/final upload, exact + stored bytes, replay, concurrent completion and callback/scanner recovery. + Sentinel streams prove pre-body denials; malformed/empty/oversize input leaves + no admitted object. Independent store conformance is not end-to-end API proof. +- Current guide-intake docs, examples and clients use only the standard flow. Existing + required downstream history and locked-policy behavior remains protected. + +## Risk and review routing + +- Risk class: L1 +- Required reviewers: architecture, reuse_dedup, security, qa, product_ops, + test_delta, documentation; ci_integrity for affected selection/coverage gates. +- Human review focus: simple guide/document API, exact authorization and + membership, no premature inference, no retained alternate public flow. + +## Evidence + +Plan feasibility review precedes implementation. Verification uses focused +schema/OpenAPI and actual API/PG/MinIO cases, guard-removal probes with valid +controls, caller scans, lint, boundary checks, links and Commitrail validation. +Hosted CI owns the full suite and coverage. Runtime/model behavior is unchanged; +upload/dispatch proof must not be described as a new real-model evaluation. + +## Reconciliation + +- Source reconciliation incorporates merged #397 at `47b837d0`, preserving its + canonical task authority, retired public writers and coverage configuration. + Shared guide/API drill callers use the complete guide creation contract. +- Next usable boundary: POL-05A, then AUTH-12F4 and POL-05B. +- The composite guide/source authorization and exact create replay are verified + together with removal of the old public source command. The independent source + consent and immutable document-set lineage remain internal. + + +## Test replacement trace + +- Standalone source-snapshot request validation now exercises `guide.documents` + with otherwise valid create fields; unsafe locators, unknown storage fields, + ordering, duplicates and bounds remain covered. +- Metadata-only broker tests are consolidated into guide creation's explicit + no-dispatch/no-provider proof. Actual final-upload dispatch, exact replay and + post-commit continuation recovery use API/PostgreSQL/MinIO integration. +- Tests requiring a second document set on the same guide are removed. New + creation-pair database proof rejects invalid paired custody and includes a + discriminating probe with only that guard disabled. Retained immutable source + item/hash and downstream locked-policy tests remain. +- Source consent still has its own internal authorization decision and replay + record. Composite create tests prove both records share the public request key; + current-authority replay and late-write rollback cover the whole transaction. +- Downstream ART/index fixtures that intentionally seed product prerequisites + explicitly suspend the new creation-only trigger alongside their existing + setup-custody exclusions. The guide creation tests exercise all guards enabled; + no downstream admission or authorization assertion is removed. + +The cross-module adapter accepts the typed document-target port; the PROJECTS +route composes its own concrete lookup. This avoids a new private module edge. +The larger diff replaces one connected public workflow and its callers/tests; +splitting removal, schema requirements or upload exposure would leave an +incomplete client contract between merges. + +### Reconciliation with merged task authority + +The guide creation migration follows the merged `0017_task_project_authority` as +`0018_guide_document_creation`; one linear Alembic head preserves both changes. +Creation custody validates the actual waiting setup state, including no readiness +timestamp or queued Celery task, rather than trusting only the response projection. +Raw-SQL negative cases and predicate-removal controls prove each new guard. +The public upload contract continues to require the exact declared media type; +HTTP media-type case and parameters are normalized before that comparison; +a different declared media type is still rejected before body consumption. diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 76d45a40f..d5cb37b1e 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -30,7 +30,8 @@ _PROJECT_ROLE_HEAD_REVISION = "0014_project_role_scope" _RUNTIME_CONFIGURATION_HEAD_REVISION = "0015_guide_runtime_configuration" _GUIDE_DOCUMENT_HEAD_REVISION = "0016_guide_document_runtime" -_CURRENT_HEAD_REVISION = "0017_task_project_authority" +_TASK_AUTHORITY_REVISION = "0017_task_project_authority" +_CURRENT_HEAD_REVISION = "0018_guide_document_creation" _RECREATE_GUIDANCE = ( "Workstream v0.1 requires a fresh database; recreate this database before " "running the 0001_v01_baseline migration" @@ -73,6 +74,7 @@ def do_run_migrations(connection: Connection) -> None: (_PROJECT_ROLE_HEAD_REVISION,), (_RUNTIME_CONFIGURATION_HEAD_REVISION,), (_GUIDE_DOCUMENT_HEAD_REVISION,), + (_TASK_AUTHORITY_REVISION,), (_CURRENT_HEAD_REVISION,), ): raise RuntimeError(_RECREATE_GUIDANCE) diff --git a/backend/alembic/versions/0018_guide_document_creation.py b/backend/alembic/versions/0018_guide_document_creation.py new file mode 100644 index 000000000..4740857e5 --- /dev/null +++ b/backend/alembic/versions/0018_guide_document_creation.py @@ -0,0 +1,95 @@ +"""Require one atomic guide, document declaration and initial setup creation.""" + +from alembic import op + +revision = "0018_guide_document_creation" +down_revision = "0017_task_project_authority" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Constrain new mutations without rewriting or deleting retained evidence.""" + op.execute(""" + CREATE FUNCTION require_guide_document_creation_pair() RETURNS trigger + LANGUAGE plpgsql AS $$ + DECLARE + guide_key text; + root_row guide_mutation_idempotency_records%rowtype; + source_row guide_mutation_idempotency_records%rowtype; + guide_row project_guides%rowtype; + snapshot_row guide_source_snapshots%rowtype; + setup_row project_setup_runs%rowtype; + expected_documents jsonb; + BEGIN + IF TG_TABLE_NAME = 'project_guides' THEN + guide_key := NEW.id; + ELSIF TG_TABLE_NAME = 'guide_source_snapshots' THEN + guide_key := NEW.guide_id; + ELSE + IF NEW.action_id = 'project.guide.create' THEN + guide_key := NEW.resource_id; + ELSIF NEW.action_id = 'project.guide_source_snapshot.create' THEN + SELECT guide_id INTO guide_key FROM guide_source_snapshots WHERE id=NEW.resource_id; + ELSE RETURN NULL; + END IF; + END IF; + SELECT * INTO guide_row FROM project_guides WHERE id=guide_key; + SELECT * INTO root_row FROM guide_mutation_idempotency_records + WHERE resource_id=guide_key AND action_id='project.guide.create'; + SELECT * INTO snapshot_row FROM guide_source_snapshots WHERE guide_id=guide_key; + SELECT * INTO source_row FROM guide_mutation_idempotency_records + WHERE resource_id=snapshot_row.id AND action_id='project.guide_source_snapshot.create'; + SELECT * INTO setup_row FROM project_setup_runs WHERE id=source_row.setup_run_id; + IF guide_row.id IS NULL OR root_row.id IS NULL OR source_row.id IS NULL + OR snapshot_row.id IS NULL OR setup_row.id IS NULL + OR (SELECT count(*) FROM guide_source_snapshots WHERE guide_id=guide_key) <> 1 + OR (SELECT count(*) FROM guide_mutation_idempotency_records + WHERE resource_id=guide_key AND action_id='project.guide.create') <> 1 + OR (SELECT count(*) FROM guide_mutation_idempotency_records + WHERE resource_id=snapshot_row.id AND action_id='project.guide_source_snapshot.create') <> 1 + OR root_row.status <> 'committed' OR source_row.status <> 'committed' + OR root_row.operation_generation <> 1 OR source_row.operation_generation <> 1 + OR snapshot_row.creation_generation <> 1 + OR (root_row.actor_profile_id,root_row.identity_link_id,root_row.project_id,root_row.idempotency_key) + IS DISTINCT FROM + (source_row.actor_profile_id,source_row.identity_link_id,source_row.project_id,source_row.idempotency_key) + OR root_row.project_id IS DISTINCT FROM guide_row.project_id + OR (snapshot_row.project_id,snapshot_row.guide_version) + IS DISTINCT FROM (guide_row.project_id,guide_row.version) + OR (setup_row.project_id,setup_row.guide_id,setup_row.guide_version, + setup_row.source_snapshot_id,setup_row.source_snapshot_hash) + IS DISTINCT FROM (guide_row.project_id,guide_row.id,guide_row.version, + snapshot_row.id,snapshot_row.bundle_hash) + OR root_row.response_json::jsonb->'setup'->>'id' IS DISTINCT FROM setup_row.id + OR root_row.response_json::jsonb->'setup'->>'status' IS DISTINCT FROM 'awaiting_documents' + OR setup_row.status IS DISTINCT FROM 'awaiting_documents' + OR setup_row.current_step IS DISTINCT FROM 'awaiting_documents' + OR setup_row.documents_ready_at IS NOT NULL + OR setup_row.celery_task_id IS NOT NULL + THEN + RAISE EXCEPTION 'guide document creation pair is invalid' USING ERRCODE='23514'; + END IF; + SELECT jsonb_agg(jsonb_build_object( + 'document_id',id,'label',source_label,'media_type',media_type,'order',item_order + ) ORDER BY item_order) INTO expected_documents + FROM guide_source_snapshot_items WHERE source_snapshot_id=snapshot_row.id; + IF expected_documents IS NULL + OR root_row.response_json::jsonb->'documents' IS DISTINCT FROM expected_documents THEN + RAISE EXCEPTION 'guide document creation response is invalid' USING ERRCODE='23514'; + END IF; + RETURN NULL; + END $$; + """) + for table in ("project_guides", "guide_source_snapshots", "guide_mutation_idempotency_records"): + op.execute(f""" + CREATE CONSTRAINT TRIGGER require_document_creation_pair + AFTER INSERT ON {table} + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW + EXECUTE FUNCTION require_guide_document_creation_pair(); + """) + + +def downgrade() -> None: + """Do not restore independent document-set creation.""" + raise RuntimeError("guide document creation custody cannot be downgraded") diff --git a/backend/app/adapters/artifacts/__init__.py b/backend/app/adapters/artifacts/__init__.py index 37703866c..74c5a676b 100644 --- a/backend/app/adapters/artifacts/__init__.py +++ b/backend/app/adapters/artifacts/__init__.py @@ -17,6 +17,7 @@ from app.adapters.tasks import task_submission_context_port from app.db.session import get_db_session from app.interfaces.artifact_operations import GuideArtifactIngestCommand +from app.modules.projects.api.guide_documents import GuideDocumentUploadTargetPort from app.modules.artifacts.api import SubmissionBundlePreparationCommand from app.interfaces.artifacts import ( ARTIFACT_STORE_CAPABILITY_KEY, @@ -177,6 +178,7 @@ def get_guide_artifact_ingest_command( ArtifactInternalAuthority, Depends(get_artifact_internal_authority), ], + targets: GuideDocumentUploadTargetPort, ) -> GuideArtifactIngestCommand: """Compose real guide ingest lazily so denial performs no provider I/O.""" from app.modules.artifacts.service import ( @@ -223,7 +225,11 @@ async def runtime(): from app.adapters.artifacts.internal_workers import continue_guide_setup_after_stored_document service = GuideArtifactIngestService(runtime, authority, continue_guide_setup_after_stored_document) - return PreparedGuideArtifactIngestCommand(service, authority) + return PreparedGuideArtifactIngestCommand( + service, authority, targets, + maximum_document_bytes=min(settings.artifact_maximum_bytes, settings.project_agent_max_document_bytes), + maximum_total_bytes=settings.project_agent_max_total_document_bytes, + ) def get_submission_bundle_preparation_authorization( diff --git a/backend/app/api/router.py b/backend/app/api/router.py index b4f08564f..afa7f5ea1 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -4,7 +4,7 @@ from fastapi import APIRouter -from app.api.routes.auth import actors_router, router as auth_router +from app.api.routes.auth import actors_router from app.api.routes.health import router as health_router from app.modules.checkers.router import router as checkers_router from app.api.routes.artifact_submissions import router as artifact_submission_router @@ -19,7 +19,6 @@ api_router = APIRouter() api_router.include_router(health_router) api_router.include_router(health_router, prefix="/api/v1") -api_router.include_router(auth_router, prefix="/api/v1") api_router.include_router(actors_router, prefix="/api/v1") api_router.include_router(authorization_router, prefix="/api/v1") api_router.include_router(project_create_router, prefix="/api/v1") diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 563cdf332..8119dbf6a 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -12,7 +12,6 @@ from app.api.deps.auth import ( actor_registry_http_error, actor_registry_unavailable_error, - get_registered_actor, ) from app.api.deps.authorization import ( enforce_human_authorization_read, @@ -32,27 +31,10 @@ from app.modules.authorization.read_service import ActorAuthorizationContextReadService from app.modules.authorization.runtime import authorization_resource_selector_id from app.modules.projects.service import ProjectService -from app.schemas.auth import ActorContext, ActorResponse -router = APIRouter(prefix="/auth", tags=["auth"]) actors_router = APIRouter(prefix="/actors", tags=["actors"]) -@router.get("/me", response_model=ActorResponse) -async def read_current_actor( - actor: Annotated[ActorContext, Depends(get_registered_actor)], -) -> ActorResponse: - """Return the actor context derived from the current bearer token. - - Args: - actor: Verified actor resolved by auth dependencies. - - Returns: - Public actor response including audit context. - """ - return ActorResponse.from_actor(actor) - - @actors_router.get( "/me", response_model=ActorProfileSelfResponse, diff --git a/backend/app/interfaces/artifact_operations.py b/backend/app/interfaces/artifact_operations.py index 06a32a3b9..12f0fedff 100644 --- a/backend/app/interfaces/artifact_operations.py +++ b/backend/app/interfaces/artifact_operations.py @@ -141,9 +141,10 @@ async def ingest( authorization_context: AuthorizationContext, project_id: UUID, guide_id: UUID, - guide_source_snapshot_id: UUID, source_item_id: UUID, idempotency_key: UUID, + content_type: str, + content_length: int | None, byte_source: AsyncIterable[bytes], ) -> GuideArtifactIngestResult: """Prepare authority before delegating to durable byte ingestion.""" diff --git a/backend/app/modules/artifacts/authorization.py b/backend/app/modules/artifacts/authorization.py index 1b61d5c34..e7ba58132 100644 --- a/backend/app/modules/artifacts/authorization.py +++ b/backend/app/modules/artifacts/authorization.py @@ -328,47 +328,6 @@ async def consume( def close(self) -> None: ... -class DenyGuideArtifactPreparedAuthorization: - """Keep guide byte ingest unavailable until exact AUTH activation.""" - - @asynccontextmanager - async def transaction(self): - """Provide no durable state while the action remains unavailable.""" - yield - - async def prepare( - self, - *, - authorization_context: AuthorizationContext, - project_id: UUID, - guide_id: UUID, - guide_source_snapshot_id: UUID, - guide_source_item_id: UUID, - idempotency_key: UUID, - ) -> PreparedAuthorizationHandle: - del ( - authorization_context, - project_id, - guide_id, - guide_source_snapshot_id, - guide_source_item_id, - idempotency_key, - ) - raise ArtifactAuthorityDeniedError("guide artifact ingest is unavailable") - - async def consume( - self, - *, - prepared_authorization: PreparedAuthorizationHandle, - facts: GuideArtifactIngestAuthorityFacts, - ) -> UUID: - del prepared_authorization, facts - raise ArtifactAuthorityDeniedError("guide artifact ingest is unavailable") - - def close(self) -> None: - """Deny-only adapters hold no capability state.""" - - class PreparedGuideArtifactAuthorization: """Activate exact Project Manager guide ingest through AUTH-owned PREP.""" diff --git a/backend/app/modules/artifacts/repository.py b/backend/app/modules/artifacts/repository.py index bdd360a8d..4e342b3e7 100644 --- a/backend/app/modules/artifacts/repository.py +++ b/backend/app/modules/artifacts/repository.py @@ -40,6 +40,10 @@ from app.modules.tasks.models import Submission, WorkstreamTask +class GuideSourceIngestConflict(ValueError): + """Authorized ingest differs from the immutable stored document.""" + + @dataclass(frozen=True, slots=True) class GuideAdmissionFacts: """Authoritative project ownership for one guide source item.""" @@ -149,7 +153,7 @@ async def stage_guide_source_ingest( byte_count: int, media_type: str, ) -> GuideSourceArtifactIngest: - """Persist server-prepared facts after locking exact legacy descriptor lineage.""" + """Persist server-prepared facts after locking exact declared document lineage.""" lineage = await self.get_guide_lineage(str(guide_source_item_id)) if ( lineage is None @@ -173,7 +177,7 @@ async def stage_guide_source_ingest( or existing.byte_count != byte_count or existing.media_type != media_type ): - raise ValueError("guide source ingest conflicts with prepared bytes") + raise GuideSourceIngestConflict("guide source ingest conflicts with prepared bytes") return existing ingest = GuideSourceArtifactIngest( id=str(uuid4()), diff --git a/backend/app/modules/artifacts/service.py b/backend/app/modules/artifacts/service.py index 0ab784b42..b185132fb 100644 --- a/backend/app/modules/artifacts/service.py +++ b/backend/app/modules/artifacts/service.py @@ -21,6 +21,8 @@ ArtifactStore, ArtifactCommitment, ArtifactIntegrityError, + ArtifactLimitExceededError, + ArtifactInputMismatchError, ArtifactStoreError, ArtifactStoreUnavailableError, ArtifactObjectMissingError, @@ -30,7 +32,7 @@ artifact_store_namespace_material, ) from app.interfaces.external_services import ExternalServiceAdapterIdentity -from app.modules.projects.api.guide_documents import DOCUMENT_EXTENSIONS +from app.modules.projects.api.guide_documents import DOCUMENT_EXTENSIONS, GuideDocumentUploadTargetPort from app.modules.artifacts.guide_formats import BoundGuideFormatInspector, GuideFormatDetector, GuideFormatLimits from app.interfaces.artifact_operations import ( ArtifactRecoveryRequest, @@ -59,7 +61,7 @@ ArtifactAdmissionMetrics, artifact_admission_metrics, ) -from app.modules.artifacts.repository import ArtifactRepository +from app.modules.artifacts.repository import ArtifactRepository, GuideSourceIngestConflict from app.modules.artifacts.authorization import ( GuideArtifactPreparedAuthorization, guide_ingest_prepared_request_digest, @@ -245,7 +247,7 @@ async def prepare_and_admit( )) if (classification.status != "classified" or classification.detected_format != DOCUMENT_EXTENSIONS[media_type]): - raise ArtifactAdmissionRelationshipError("guide document format is invalid") + raise ArtifactInputMismatchError("guide document format is invalid") admission = await admission_service.admit( GuideArtifactAdmissionRequest( project_id=request.project_id, @@ -312,9 +314,14 @@ def __init__( self, service: GuideArtifactIngestService, authority: GuideArtifactPreparedAuthorization, + targets: GuideDocumentUploadTargetPort, + *, maximum_document_bytes: int, maximum_total_bytes: int, ) -> None: self._service = service self._authority = authority + self._targets = targets + self._maximum_document_bytes = maximum_document_bytes + self._maximum_total_bytes = maximum_total_bytes async def ingest( self, @@ -322,9 +329,10 @@ async def ingest( authorization_context: AuthorizationContext, project_id: UUID, guide_id: UUID, - guide_source_snapshot_id: UUID, source_item_id: UUID, idempotency_key: UUID, + content_type: str, + content_length: int | None, byte_source: AsyncIterable[bytes], ) -> GuideArtifactIngestResult: authority_transaction = self._authority.transaction() @@ -332,6 +340,10 @@ async def ingest( try: await authority_transaction.__aenter__() transaction_open = True + target = await self._targets.resolve(project_id, guide_id, source_item_id, for_update=False) + if target is None: + raise ArtifactAdmissionRelationshipError("guide document not found") + guide_source_snapshot_id = target.snapshot_id prepared_authorization = await self._authority.prepare( authorization_context=authorization_context, project_id=project_id, @@ -340,6 +352,24 @@ async def ingest( guide_source_item_id=source_item_id, idempotency_key=idempotency_key, ) + locked = await self._targets.resolve(project_id, guide_id, source_item_id, for_update=True) + if (locked is None or (locked.snapshot_id, locked.setup_id, locked.setup_generation, locked.media_type) + != (target.snapshot_id, target.setup_id, target.setup_generation, target.media_type)): + raise ArtifactAdmissionRelationshipError("guide document metadata does not match") + if content_type != locked.media_type: + raise ArtifactInputMismatchError("guide document content type does not match") + limit = min(self._maximum_document_bytes, self._maximum_total_bytes - locked.other_document_bytes) + if limit <= 0 or (content_length is not None and (content_length < 0 or content_length > limit)): + raise ArtifactLimitExceededError("guide document exceeds maximum bytes") + + async def bounded_body(): + total = 0 + async for chunk in byte_source: + total += len(chunk) + if total > limit: + raise ArtifactLimitExceededError("guide document exceeds maximum bytes") + yield chunk + async with self._service.runtime() as runtime: preparation, admission_service, orchestrator = runtime prepared, admission = await self._service.prepare_and_admit( @@ -363,7 +393,7 @@ async def ingest( idempotency_key=idempotency_key, ), logical_role="guide_source", - byte_source=byte_source, + byte_source=bounded_body(), ), preparation, admission_service, @@ -1926,6 +1956,8 @@ async def admit( byte_count=commitment.byte_count, media_type=commitment.media_type, ) + except GuideSourceIngestConflict as exc: + raise ArtifactAdmissionConflictError(str(exc)) from exc except ValueError as exc: raise ArtifactAdmissionRelationshipError(str(exc)) from exc submission_facts: _AdmissionFacts | None = None @@ -1977,6 +2009,8 @@ async def admit( request_digest = canonical_json_hash( { "operation_identity": facts.operation_identity, + **({"guide_ingest_request_digest": request.request_digest} + if type(request) is GuideArtifactAdmissionRequest else {}), "request_type": facts.request_type, "producer_type": facts.producer_type, "producer_ref": facts.producer_ref, diff --git a/backend/app/modules/projects/api/guide_documents.py b/backend/app/modules/projects/api/guide_documents.py index 7c9be87eb..c626634f9 100644 --- a/backend/app/modules/projects/api/guide_documents.py +++ b/backend/app/modules/projects/api/guide_documents.py @@ -246,3 +246,23 @@ class ProjectGuideDocumentScopePort(Protocol): async def lock_manifest_source(self, request: GuideDocumentManifestRequest) -> ProjectGuideDocumentLineage: ... async def lock_access_attempt(self, attempt_id: UUID, manifest: GuideDocumentManifest) -> None: ... + + +@dataclass(frozen=True, slots=True) +class GuideDocumentUploadTarget: + """Internal immutable membership resolved from public document selectors.""" + + snapshot_id: UUID + setup_id: UUID + setup_generation: int + media_type: GuideDocumentMediaType + other_document_bytes: int + + +class GuideDocumentUploadTargetPort(Protocol): + """Resolve exact creation membership within the caller's AUTH/ART transaction.""" + + async def resolve( + self, project_id: UUID, guide_id: UUID, document_id: UUID, *, for_update: bool, + ) -> GuideDocumentUploadTarget | None: + """Return current membership, optionally locking the owning aggregate.""" diff --git a/backend/app/modules/projects/document_upload.py b/backend/app/modules/projects/document_upload.py new file mode 100644 index 000000000..4aac6e8ad --- /dev/null +++ b/backend/app/modules/projects/document_upload.py @@ -0,0 +1,122 @@ +"""Resolve public guide document selectors without exposing source snapshots.""" + +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.orm import aliased +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.projects.api.guide_documents import GuideDocumentUploadTarget +from app.modules.projects.models import ( + GuideMutationIdempotencyRecord, + GuideSourceArtifactIngest, + GuideSourceSnapshot, + GuideSourceSnapshotItem, + Project, + ProjectGuide, + ProjectSetupRun, +) + + +class ProjectGuideDocumentUploadTargets: + """PROJECTS-owned exact membership lookup sharing the ingest transaction.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def resolve( + self, project_id: UUID, guide_id: UUID, document_id: UUID, *, for_update: bool + ) -> GuideDocumentUploadTarget | None: + if for_update: + project = await self._session.scalar( + select(Project) + .where( + Project.id == str(project_id), + ) + .with_for_update() + ) + if project is None: + return None + root = aliased(GuideMutationIdempotencyRecord) + source = aliased(GuideMutationIdempotencyRecord) + latest_setup = ( + select(func.max(ProjectSetupRun.setup_generation)) + .where( + ProjectSetupRun.guide_id == str(guide_id), + ) + .correlate(None) + .scalar_subquery() + ) + statement = ( + select( + GuideSourceSnapshot.id, + ProjectSetupRun.id.label("setup_id"), + ProjectSetupRun.setup_generation, + GuideSourceSnapshotItem.media_type, + ) + .select_from(GuideSourceSnapshotItem) + .join( + GuideSourceSnapshot, + GuideSourceSnapshot.id == GuideSourceSnapshotItem.source_snapshot_id, + ) + .join(ProjectGuide, ProjectGuide.id == GuideSourceSnapshot.guide_id) + .join(ProjectSetupRun, ProjectSetupRun.source_snapshot_id == GuideSourceSnapshot.id) + .join(source, source.resource_id == GuideSourceSnapshot.id) + .join(root, root.resource_id == ProjectGuide.id) + .where( + ProjectGuide.id == str(guide_id), + ProjectGuide.project_id == str(project_id), + ProjectGuide.status == "draft", + GuideSourceSnapshot.project_id == str(project_id), + GuideSourceSnapshot.guide_version == ProjectGuide.version, + GuideSourceSnapshot.creation_generation == 1, + GuideSourceSnapshotItem.id == str(document_id), + GuideSourceSnapshotItem.source_kind == "document", + GuideSourceSnapshotItem.ingestion_adapter == "upload", + ProjectSetupRun.project_id == str(project_id), + ProjectSetupRun.guide_id == str(guide_id), + ProjectSetupRun.guide_version == ProjectGuide.version, + ProjectSetupRun.source_snapshot_hash == GuideSourceSnapshot.bundle_hash, + ProjectSetupRun.setup_generation == latest_setup, + source.action_id == "project.guide_source_snapshot.create", + root.action_id == "project.guide.create", + root.status == "committed", + source.status == "committed", + root.idempotency_key == source.idempotency_key, + root.actor_profile_id == source.actor_profile_id, + root.identity_link_id == source.identity_link_id, + root.project_id == source.project_id, + root.project_id == str(project_id), + root.response_json["setup"]["id"].as_string() == source.setup_run_id, + ) + ) + if for_update: + statement = statement.with_for_update( + of=( + ProjectGuide, + GuideSourceSnapshot, + GuideSourceSnapshotItem, + ProjectSetupRun, + ) + ) + row = (await self._session.execute(statement)).one_or_none() + if row is None: + return None + used = await self._session.scalar( + select(func.coalesce(func.sum(GuideSourceArtifactIngest.byte_count), 0)) + .join( + GuideSourceSnapshotItem, + GuideSourceSnapshotItem.id == GuideSourceArtifactIngest.source_item_id, + ) + .where( + GuideSourceSnapshotItem.source_snapshot_id == row.id, + GuideSourceSnapshotItem.id != str(document_id), + ) + ) + return GuideDocumentUploadTarget( + snapshot_id=UUID(row.id), + setup_id=UUID(row.setup_id), + setup_generation=row.setup_generation, + media_type=row.media_type, + other_document_bytes=int(used), + ) diff --git a/backend/app/modules/projects/guide_mutation_router.py b/backend/app/modules/projects/guide_mutation_router.py index 2009d81f8..4952dfa2c 100644 --- a/backend/app/modules/projects/guide_mutation_router.py +++ b/backend/app/modules/projects/guide_mutation_router.py @@ -25,9 +25,8 @@ GuideMutationService, ) from app.modules.projects.schemas import ( - GuideSourceSnapshotCreate, - GuideSourceSnapshotResponse, ProjectGuideCreate, + ProjectGuideCreateResponse, ProjectGuideResponse, ProjectGuideUpdate, ) @@ -38,7 +37,7 @@ def require_guide_mutation_key( - idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], + idempotency_key: Annotated[str, Header(alias="Idempotency-Key", json_schema_extra={"format": "uuid"})], ) -> UUID: """Validate replay custody before actor provisioning.""" try: @@ -163,7 +162,7 @@ async def _finish(session, outcome): @router.post( "/{project_id}/guides", - response_model=ProjectGuideResponse, + response_model=ProjectGuideCreateResponse, status_code=201, openapi_extra={"x-workstream-action-id": ActionId.PROJECT_GUIDE_CREATE.value}, ) @@ -207,28 +206,3 @@ async def update_guide( ) except ProjectServiceError as exc: raise _error(exc) from exc - - -@router.post( - "/{project_id}/guides/{guide_id}/source-snapshots", - response_model=GuideSourceSnapshotResponse, - status_code=201, - openapi_extra={"x-workstream-action-id": ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE.value}, -) -async def create_snapshot( - project_id: UUID, - guide_id: UUID, - payload: GuideSourceSnapshotCreate, - authorization: Annotated[tuple, Depends(guide_authorization)], - session: Annotated[AsyncSession, Depends(get_db_session)], -): - key, resolved, prepared = authorization - try: - return await _finish( - session, - await GuideMutationService(session).create_snapshot( - resolved, prepared, key, project_id, guide_id, payload - ), - ) - except ProjectServiceError as exc: - raise _error(exc) from exc diff --git a/backend/app/modules/projects/guide_mutation_service.py b/backend/app/modules/projects/guide_mutation_service.py index 2465913ea..5b0254604 100644 --- a/backend/app/modules/projects/guide_mutation_service.py +++ b/backend/app/modules/projects/guide_mutation_service.py @@ -27,10 +27,12 @@ ) from app.modules.projects.repository import ProjectRepository from app.modules.projects.schemas import ( - GuideSourceSnapshotCreate, GuideSourceSnapshotItemResponse, GuideSourceSnapshotResponse, ProjectGuideCreate, + ProjectGuideCreateResponse, + ProjectGuideDocumentResponse, + ProjectGuideWaitingSetupResponse, ProjectGuideResponse, ProjectGuideUpdate, ) @@ -41,9 +43,9 @@ GuideVersionConflict, ProjectNotFound, ProjectServiceError, - PolicySetupBlocked, build_guide_source_snapshot_manifest, build_guide_source_snapshot_items, + ProjectService, ) @@ -57,7 +59,7 @@ class GuideMutationIdempotencyConflict(ProjectServiceError): class GuideMutationOutcome: """Route-owned transaction result and optional post-commit dispatch facts.""" - response: ProjectGuideResponse | GuideSourceSnapshotResponse + response: ProjectGuideResponse | ProjectGuideCreateResponse replayed: bool setup_run_id: str | None = None setup_generation: int | None = None @@ -85,7 +87,7 @@ def _input( operation_id: UUID, ) -> tuple[PreparedAuthorizationInput, str]: body_value = body.model_dump(mode="json", exclude_unset=True) - if action is ActionId.PROJECT_GUIDE_CREATE: + if action in {ActionId.PROJECT_GUIDE_CREATE, ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE}: examples = validate_task_examples(body.task_examples) body_value.pop("task_examples") body_value["task_examples_hash"] = task_examples_hash(examples) @@ -197,16 +199,26 @@ async def create_guide( target_resource_id=guide_id, operation_id=operation_id, ) - existing = await self._existing(resolved, action, key, digest, ProjectGuideResponse) - if existing: + existing = await self._replay_creation(resolved, prepared, key, project_id, payload, digest) + if existing is not None: return existing + snapshot_id, source_operation_id = uuid4(), uuid4() + manifest, sanitized = build_guide_source_snapshot_manifest( + payload, snapshot_id=str(snapshot_id), generation=1, + task_examples=examples, expected_task_examples_hash=examples_hash, + ) + source_action = ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE + source_caller, source_digest = self._input( + source_action, "POST /api/v1/projects/{project_id}/guides", resolved, key, payload, + project_id=project_id, guide_id=guide_id, + target_resource_id=snapshot_id, operation_id=source_operation_id, + ) handle = await self._prepare( - prepared, - action, - caller, - project_id, - guide_id=None, - target_kind="guide_create", + prepared, action, caller, project_id, guide_id=None, target_kind="guide_create", + ) + source_handle = await self._prepare( + prepared, source_action, source_caller, project_id, + guide_id=guide_id, target_kind="source_snapshot_create", ) project = await self._repo.get_project(str(project_id), for_update=True) if project is None: @@ -214,8 +226,8 @@ async def create_guide( # A concurrent exact replay can miss the optimistic lookup and then wait # on this project lock. Re-read the ledger after the lock so the winner's # committed response takes precedence over the natural version conflict. - existing = await self._existing(resolved, action, key, digest, ProjectGuideResponse) - if existing: + existing = await self._replay_creation(resolved, prepared, key, project_id, payload, digest) + if existing is not None: return existing if await self._repo.get_guide_by_version(str(project_id), payload.version): raise GuideVersionConflict("guide version already exists for project") @@ -246,9 +258,27 @@ async def create_guide( resource_id=str(guide_id), operation_generation=1, ) - concurrent = self._reservation_outcome(disposition, replay, ProjectGuideResponse) - if concurrent is not None: - return concurrent + if disposition != "claimed": + raise GuideMutationIdempotencyConflict("idempotency_pending") + source_resource = self._source_resource( + project_id, guide_id, payload.version, snapshot_id, + canonical_json_hash(manifest), source_operation_id, + ) + source_decision = await prepared.consume( + source_handle, source_action, source_caller, source_resource, + ) + self._prove(source_decision, project_id) + source_disposition, source_replay = await self._replay.reserve( + actor_profile_id=resolved.profile.id, + identity_link_id=resolved.identity_link.id, + action_id=source_action.value, idempotency_key=key, + request_digest=source_digest, + resource_context_digest=source_decision.resource_context_digest, + operation_id=source_operation_id, project_id=str(project_id), + resource_id=str(snapshot_id), operation_generation=1, + ) + if source_disposition != "claimed": + raise GuideMutationIdempotencyConflict("idempotency_pending") guide = ProjectGuide( id=str(guide_id), project_id=str(project_id), @@ -272,97 +302,109 @@ async def create_guide( last_authorization_decision_event_id=str(decision.decision_id), ) await self._repo.add_guide(guide) - response = ProjectGuideResponse.model_validate(guide) - await self._replay.complete(replay, response_json=response.model_dump(mode="json")) - return GuideMutationOutcome(response, False) - - async def create_snapshot( - self, - resolved, - prepared, - key: UUID, - project_id: UUID, - guide_id: UUID, - payload: GuideSourceSnapshotCreate, - ) -> GuideMutationOutcome: - action = ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE - snapshot_id, operation_id = uuid4(), uuid4() - caller, digest = self._input( - action, - "POST /api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots", - resolved, - key, - payload, - project_id=project_id, - guide_id=guide_id, - target_resource_id=snapshot_id, - operation_id=operation_id, + items, setup = await self._initialize_documents( + resolved, guide, snapshot_id, manifest, sanitized, source_decision, source_replay, ) - existing = await self._existing(resolved, action, key, digest, GuideSourceSnapshotResponse) - if existing: - return existing - handle = await self._prepare( - prepared, - action, - caller, - project_id, - guide_id=guide_id, - target_kind="source_snapshot_create", + response = ProjectGuideCreateResponse( + **ProjectGuideResponse.model_validate(guide).model_dump(), + documents=[ProjectGuideDocumentResponse( + document_id=UUID(item.id), label=item.source_label, + media_type=item.media_type, order=item.item_order, + ) for item in items], + setup=ProjectGuideWaitingSetupResponse(id=UUID(setup.id)), ) - project = await self._repo.get_project(str(project_id), for_update=True) - guide = await self._repo.lock_project_guide(str(guide_id)) - if project is None: - raise ProjectNotFound("project not found") - if guide is None or guide.project_id != str(project_id): - raise GuideNotFound("guide not found") - if guide.status != "draft": - raise GuideEditBlocked("only draft guides can receive source snapshots") - predecessor = await self._repo.lock_latest_guide_source_snapshot( - str(project_id), guide.id, guide.version - ) - generation = (predecessor.creation_generation or 0) + 1 if predecessor else 1 - manifest, sanitized = build_guide_source_snapshot_manifest( - payload, - snapshot_id=str(snapshot_id), - generation=generation, - task_examples=guide.task_examples, - expected_task_examples_hash=guide.task_examples_hash, - ) - try: - snapshot_hash = canonical_json_hash(manifest) - except ValueError: - raise PolicySetupBlocked("canonical JSON cannot contain non-finite numbers") from None - resource = ProjectGuideSourceSnapshotMutationResourceContext( + await self._replay.complete(replay, response_json=response.model_dump(mode="json")) + return GuideMutationOutcome(response, False, setup.id, setup.setup_generation) + + @staticmethod + def _source_resource(project_id, guide_id, version, snapshot_id, bundle_hash, operation_id): + return ProjectGuideSourceSnapshotMutationResourceContext( resource_type="project_guide_source_snapshot_mutation", - resource_id=snapshot_id, - operation_id=operation_id, - scope_project_id=project_id, - guide_id=guide_id, - guide_version=guide.version, - guide_status=guide.status, - source_snapshot_id=snapshot_id, - source_snapshot_hash=snapshot_hash, - predecessor_snapshot_id=UUID(predecessor.id) if predecessor else None, - predecessor_snapshot_hash=predecessor.bundle_hash if predecessor else None, - operation_generation=generation, + resource_id=snapshot_id, operation_id=operation_id, + scope_project_id=project_id, guide_id=guide_id, guide_version=version, + guide_status="draft", source_snapshot_id=snapshot_id, + source_snapshot_hash=bundle_hash, predecessor_snapshot_id=None, + predecessor_snapshot_hash=None, operation_generation=1, ) - decision = await prepared.consume(handle, action, caller, resource) - self._prove(decision, project_id) - disposition, replay = await self._replay.reserve( - actor_profile_id=resolved.profile.id, - identity_link_id=resolved.identity_link.id, - action_id=action.value, - idempotency_key=key, - request_digest=digest, - resource_context_digest=decision.resource_context_digest, - operation_id=operation_id, - project_id=str(project_id), - resource_id=str(snapshot_id), - operation_generation=generation, + + async def _replay_creation(self, resolved, prepared, key, project_id, payload, digest): + """Reauthorize both immutable original operations before returning a replay.""" + action = ActionId.PROJECT_GUIDE_CREATE + source_action = ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE + root = await self._replay.find(resolved.profile.id, action.value, key) + source = await self._replay.find(resolved.profile.id, source_action.value, key) + if root is None and source is None: + return None + if root is None or source is None: + raise GuideMutationIdempotencyConflict("idempotency_pending") + for record in (root, source): + if (record.identity_link_id != resolved.identity_link.id + or record.project_id != str(project_id)): + raise GuideMutationIdempotencyConflict("idempotency_mismatch") + if record.status != "committed" or record.response_json is None: + raise GuideMutationIdempotencyConflict("idempotency_pending") + if root.request_digest != digest: + raise GuideMutationIdempotencyConflict("idempotency_mismatch") + guide = await self._repo.get_guide_by_version(str(project_id), payload.version) + snapshot = await self._repo.get_guide_source_snapshot(source.resource_id) + setup = await self._repo.get_project_setup_run(source.setup_run_id) if source.setup_run_id else None + if (guide is None or guide.id != root.resource_id or snapshot is None + or snapshot.guide_id != guide.id or snapshot.project_id != guide.project_id + or snapshot.guide_version != guide.version or snapshot.creation_generation != 1 + or setup is None or setup.guide_id != guide.id + or setup.source_snapshot_id != snapshot.id + or setup.source_snapshot_hash != snapshot.bundle_hash): + raise GuideMutationIdempotencyConflict("idempotency_mismatch") + items = await self._repo.list_guide_source_snapshot_items(snapshot.id) + await ProjectService(self._session).validate_source_snapshot_integrity( + snapshot, GuideMutationIdempotencyConflict, persisted_items=items, ) - concurrent = self._reservation_outcome(disposition, replay, GuideSourceSnapshotResponse) - if concurrent is not None: - return concurrent + response = ProjectGuideCreateResponse.model_validate(root.response_json) + if (str(response.setup.id) != setup.id + or [str(item.document_id) for item in response.documents] != [item.id for item in items]): + raise GuideMutationIdempotencyConflict("idempotency_mismatch") + guide_id, snapshot_id = UUID(guide.id), UUID(snapshot.id) + examples = validate_task_examples(payload.task_examples) + resources = ( + ProjectGuideMutationResourceContext( + resource_type="project_guide_mutation", resource_id=guide_id, + operation_id=root.operation_id, scope_project_id=project_id, + guide_id=guide_id, target_kind="create", guide_exists=False, + operation_generation=1, request_digest=digest, + task_examples_hash=task_examples_hash(examples), task_examples_count=len(examples), + ), + self._source_resource(project_id, guide_id, guide.version, snapshot_id, + snapshot.bundle_hash, source.operation_id), + ) + for record, resource, current_action, target in ( + (root, resources[0], action, "guide_create"), + (source, resources[1], source_action, "source_snapshot_create"), + ): + caller, current_digest = self._input( + current_action, "POST /api/v1/projects/{project_id}/guides", resolved, key, payload, + project_id=project_id, guide_id=None if record is root else guide_id, + target_resource_id=resource.resource_id, operation_id=record.operation_id, + ) + if current_digest != record.request_digest: + raise GuideMutationIdempotencyConflict("idempotency_mismatch") + handle = await self._prepare( + prepared, current_action, caller, project_id, + guide_id=None if record is root else guide_id, target_kind=target, + ) + decision = await prepared.consume(handle, current_action, caller, resource) + self._prove(decision, project_id) + if decision.resource_context_digest != record.resource_context_digest: + raise GuideMutationIdempotencyConflict("idempotency_mismatch") + return GuideMutationOutcome(response, True, setup.id, setup.setup_generation) + + async def _initialize_documents( + self, resolved, guide, snapshot_id, manifest, sanitized, decision, replay, + ): + """Write the one document set inside its guide-create transaction.""" + action = ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE + project_id = UUID(guide.project_id) + generation = 1 + snapshot_hash = canonical_json_hash(manifest) provenance = dict( created_by_actor_profile_id=resolved.profile.id, created_via_identity_link_id=resolved.identity_link.id, @@ -418,12 +460,7 @@ async def create_snapshot( response_json=response.model_dump(mode="json"), setup_run_id=setup_run.id, ) - return GuideMutationOutcome( - response, - False, - setup_run.id, - setup_run.setup_generation, - ) + return items, setup_run async def update_guide( self, diff --git a/backend/app/modules/projects/router.py b/backend/app/modules/projects/router.py index 394cecfeb..460bc45bc 100644 --- a/backend/app/modules/projects/router.py +++ b/backend/app/modules/projects/router.py @@ -9,7 +9,11 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession -from app.adapters.artifacts import get_guide_artifact_ingest_command +from app.adapters.artifacts import ( + get_guide_artifact_ingest_command, get_guide_artifact_prepared_authorization, + get_artifact_internal_authority, GuideArtifactPreparedAuthorization, ArtifactInternalAuthority, +) +from app.modules.projects.document_upload import ProjectGuideDocumentUploadTargets from app.api.deps.auth import get_registered_actor from app.api.deps.authorization import ( enforce_human_authorization_read, @@ -18,15 +22,15 @@ prepared_authorization_service, ) from app.core.permissions import PermissionDenied -from app.core.api_controls import StructuredHTTPException +from app.core.api_controls import ApiErrorResponse, StructuredHTTPException from app.db.session import get_db_session +from app.interfaces.artifacts import ArtifactLimitExceededError, ArtifactInputMismatchError, ArtifactStoreError from app.interfaces.artifact_operations import ( GuideArtifactIngestCommand, ) from app.modules.artifacts.authorization import get_artifact_authorization_context from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError -from app.modules.artifacts.service import ArtifactAdmissionRelationshipError -from app.modules.authorization.runtime import AuthorizationContext +from app.modules.artifacts.service import ArtifactAdmissionRelationshipError, ArtifactAdmissionConflictError from app.modules.projects.schemas import ( ActiveGuideReadResponse, EffectiveProjectSubmissionArtifactPolicyResponse, @@ -46,6 +50,8 @@ from app.modules.projects.service import ProjectService, ProjectServiceError from app.modules.projects.guide_mutation_router import ( mutation_conflict_error, + require_guide_mutation_key, + guide_authorization_actor, sufficiency_authorization, ) from app.modules.projects.sufficiency_mutation_service import ( @@ -63,6 +69,7 @@ authorize_project_diagnostic_read, authorize_project_policy_read, ) +from app.modules.projects.api.guide_documents import DOCUMENT_EXTENSIONS from app.modules.projects.repository import ProjectRepository from app.modules.projects.guide_compilation.diagnostics import compilation_setup_response from app.modules.authorization.catalogue import ActionId @@ -224,58 +231,78 @@ async def get_project( raise project_http_error(exc) from exc +async def guide_upload_context( + key: Annotated[UUID, Depends(require_guide_mutation_key)], + request: Request, + resolved: Annotated[ResolvedActor, Depends(guide_authorization_actor)], +): + """Reuse the key-gated actor dependency before composing ingest context.""" + return key, await get_artifact_authorization_context(request, resolved) + + +def guide_document_upload_command( + request: Request, + session: Annotated[AsyncSession, Depends(get_db_session)], + authority: Annotated[GuideArtifactPreparedAuthorization, Depends(get_guide_artifact_prepared_authorization)], + internal_authority: Annotated[ArtifactInternalAuthority, Depends(get_artifact_internal_authority)], +) -> GuideArtifactIngestCommand: + """Compose the PROJECTS-owned selector lookup with the existing ART command.""" + return get_guide_artifact_ingest_command( + request, session, authority, internal_authority, ProjectGuideDocumentUploadTargets(session), + ) + + @router.post( - "/{project_id}/guides/{guide_id}/source-snapshots/{source_snapshot_id}/items/" - "{source_item_id}/artifact", + "/{project_id}/guides/{guide_id}/documents/{document_id}/content", response_model=GuideArtifactIngestResponse, status_code=status.HTTP_202_ACCEPTED, - include_in_schema=False, + openapi_extra={ + "x-workstream-action-id": ActionId.ARTIFACT_GUIDE_SOURCE_INGEST.value, + "requestBody": {"required": True, "content": { + media_type: {"schema": {"type": "string", "format": "binary"}} + for media_type in DOCUMENT_EXTENSIONS + }}, + }, + responses={404: {"model": ApiErrorResponse, "description": "Document unavailable"}, + 409: {"model": ApiErrorResponse, "description": "Upload conflicts with committed document"}, + 413: {"model": ApiErrorResponse, "description": "Document exceeds configured byte limit"}, + 422: {"model": ApiErrorResponse, "description": "Invalid upload metadata or bytes"}, + 503: {"model": ApiErrorResponse, "description": "Artifact storage unavailable"}}, ) -async def ingest_guide_source_artifact( - project_id: str, - guide_id: str, - source_snapshot_id: str, - source_item_id: str, +async def upload_guide_document( + project_id: UUID, + guide_id: UUID, + document_id: UUID, request: Request, - context: Annotated[AuthorizationContext, Depends(get_artifact_authorization_context)], - ingest: Annotated[ - GuideArtifactIngestCommand, - Depends(get_guide_artifact_ingest_command), - ], - idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, + authorization: Annotated[tuple, Depends(guide_upload_context)], + ingest: Annotated[GuideArtifactIngestCommand, Depends(guide_document_upload_command)], + content_type: Annotated[str, Header(alias="Content-Type", min_length=1)], + content_length: Annotated[int | None, Header(alias="Content-Length", ge=0)] = None, ) -> GuideArtifactIngestResponse: - """Stream one guide source through hidden, fail-closed ART ingestion.""" - try: - identifiers = ( - UUID(project_id), - UUID(guide_id), - UUID(source_snapshot_id), - UUID(source_item_id), - UUID(idempotency_key or ""), - ) - except ValueError as exc: - raise HTTPException(status_code=404, detail="Guide source not found") from exc + """Store one declared original; complete membership gates automatic setup.""" + key, context = authorization try: result = await ingest.ingest( - authorization_context=context, - project_id=identifiers[0], - guide_id=identifiers[1], - guide_source_snapshot_id=identifiers[2], - source_item_id=identifiers[3], - idempotency_key=identifiers[4], + authorization_context=context, project_id=project_id, guide_id=guide_id, + source_item_id=document_id, idempotency_key=key, + content_type=content_type.partition(";")[0].strip().lower(), + content_length=content_length, byte_source=request.stream(), ) - except ( - ArtifactAdmissionRelationshipError, - ArtifactAuthorityDeniedError, - ) as exc: - LOGGER.warning( - "guide_source_artifact_ingest_rejected type=%s reason=%s", - type(exc).__name__, - str(exc), - ) - raise HTTPException(status_code=404, detail="Guide source not found") from exc - return GuideArtifactIngestResponse.model_validate(result, from_attributes=True) + except (ArtifactAdmissionRelationshipError, ArtifactAuthorityDeniedError) as exc: + raise HTTPException(status_code=404, detail="Guide document not found") from exc + except ArtifactAdmissionConflictError as exc: + raise HTTPException(status_code=409, detail="Guide document upload conflicts") from exc + except ArtifactLimitExceededError as exc: + raise HTTPException(status_code=413, detail="Guide document exceeds byte limit") from exc + except ArtifactInputMismatchError as exc: + raise HTTPException(status_code=422, detail="Invalid guide document bytes") from exc + except ArtifactStoreError as exc: + raise HTTPException(status_code=503, detail="Guide document storage unavailable") from exc + return GuideArtifactIngestResponse( + document_id=document_id, sha256=result.sha256, byte_count=result.byte_count, + status=result.status, replayed=result.replayed, + ) @router.get( diff --git a/backend/app/modules/projects/schemas.py b/backend/app/modules/projects/schemas.py index eb9d6bb26..61d1b1c0a 100644 --- a/backend/app/modules/projects/schemas.py +++ b/backend/app/modules/projects/schemas.py @@ -62,25 +62,15 @@ class PaymentPolicyInput(BaseModel): accepted_payment_rule: str | None = None -class GuideSourceSnapshotItemInput(BaseModel): - """Input schema for one source item in a guide material bundle.""" +class ProjectGuideDocumentInput(BaseModel): + """Declare one original document belonging to a guide version.""" model_config = ConfigDict(extra="forbid") - source_kind: Literal["document"] - source_label: str = Field(max_length=500) - ingestion_adapter: Literal["upload"] + label: str = Field(min_length=1, max_length=500) media_type: GuideDocumentMediaType -class GuideSourceSnapshotCreate(BaseModel): - """Request schema for creating an immutable guide-source snapshot.""" - - model_config = ConfigDict(extra="forbid") - - items: list[GuideSourceSnapshotItemInput] = Field(min_length=1, max_length=100) - - class GuideSourceSnapshotItemResponse(BaseModel): """Response schema for a sanitized guide-source snapshot item.""" @@ -114,12 +104,11 @@ class GuideSourceSnapshotResponse(BaseModel): class GuideArtifactIngestResponse(BaseModel): - """Provider-neutral result for one hidden guide byte ingest.""" + """Public server commitment for one declared guide document upload.""" model_config = ConfigDict(extra="forbid") - put_attempt_id: UUID - operation_identity: str + document_id: UUID sha256: str byte_count: int status: str @@ -482,6 +471,7 @@ class ProjectGuideCreate(BaseModel): version: str = Field(max_length=50) change_summary: str | None = Field(default=None, max_length=1000) task_examples: ProjectGuideTaskExamples + documents: list[ProjectGuideDocumentInput] = Field(min_length=1, max_length=100) class ProjectGuideUpdate(BaseModel): @@ -512,6 +502,33 @@ class ProjectGuideResponse(BaseModel): superseded_at: datetime | None +class ProjectGuideDocumentResponse(BaseModel): + """Public document selector and immutable declaration; no storage coordinates.""" + + model_config = ConfigDict(extra="forbid") + + document_id: UUID + label: str + media_type: GuideDocumentMediaType + order: int = Field(ge=0) + + +class ProjectGuideWaitingSetupResponse(BaseModel): + """Initial setup identity returned by guide creation and exact replay.""" + + model_config = ConfigDict(extra="forbid") + + id: UUID + status: Literal["awaiting_documents"] = "awaiting_documents" + + +class ProjectGuideCreateResponse(ProjectGuideResponse): + """Created guide with its declared upload targets and initial waiting setup.""" + + documents: list[ProjectGuideDocumentResponse] = Field(min_length=1, max_length=100) + setup: ProjectGuideWaitingSetupResponse + + class PostSubmitCheckerPolicyResponse(BaseModel): """Response schema for post-submit checker policy records.""" diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index da1c7875e..70a71dcf9 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -47,7 +47,7 @@ ActiveGuideReadResponse, ActiveGuidePreSubmitCheckerPolicyResponse, EffectiveProjectSubmissionArtifactPolicyResponse, - GuideSourceSnapshotCreate, + ProjectGuideCreate, GuideSourceSnapshotItemResponse, GuideSourceSnapshotResponse, GuideSufficiencyReportCreate, @@ -1676,7 +1676,7 @@ def _payment_policy_model( def build_guide_source_snapshot_manifest( - payload: GuideSourceSnapshotCreate, + payload: ProjectGuideCreate, *, snapshot_id: str, generation: int, @@ -1695,10 +1695,10 @@ def build_guide_source_snapshot_manifest( examples_hash = task_examples_hash(examples) declared_items: list[dict[str, Any]] = [] seen_labels: set[tuple[str, str]] = set() - for item in payload.items: - source_kind = _guide_source_token(item.source_kind, "source kind") - ingestion_adapter = _guide_source_token(item.ingestion_adapter, "ingestion adapter") - source_label = _guide_source_label(item.source_label) + for item in payload.documents: + source_kind = "document" + ingestion_adapter = "upload" + source_label = _guide_source_label(item.label) duplicate_key = (source_kind, source_label) if duplicate_key in seen_labels: raise SourceSnapshotInvalid("duplicate source item label") @@ -1711,13 +1711,9 @@ def build_guide_source_snapshot_manifest( "media_type": item.media_type, } ) - sorted_declarations = sorted( - declared_items, - key=lambda item: (item["source_kind"], item["source_label"], item["ingestion_adapter"]), - ) - sorted_items = [ + ordered_items = [ {"item_id": str(uuid4()), "item_order": index, **item} - for index, item in enumerate(sorted_declarations) + for index, item in enumerate(declared_items) ] return { "schema_version": GUIDE_SOURCE_SNAPSHOT_SCHEMA_VERSION, @@ -1725,8 +1721,8 @@ def build_guide_source_snapshot_manifest( "generation": generation, "task_examples_hash": examples_hash, "task_examples_count": len(examples), - "items": sorted_items, - }, sorted_items + "items": ordered_items, + }, ordered_items def _guide_source_token(value: str, label: str) -> str: diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 56988d9d6..0640efbff 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -185,39 +185,3 @@ def audit_context(self) -> ActorAuditContext: auth_source=self.auth_source, is_dev_auth=self.is_dev_auth, ) - - -class ActorResponse(BaseModel): - """Public response schema for the current actor endpoint.""" - - actor_id: str - external_subject: str - external_issuer: str - email: str | None = None - display_name: str | None = None - roles: tuple[str, ...] - auth_source: Literal["flow", "dev_mock", "workstream_system"] - is_dev_auth: bool - audit_context: ActorAuditContext - - @classmethod - def from_actor(cls, actor: ActorContext) -> "ActorResponse": - """Build an actor response from the trusted actor context. - - Args: - actor: Trusted actor resolved by authentication. - - Returns: - Public actor response with audit context included. - """ - return cls( - actor_id=actor.actor_id, - external_subject=actor.external_subject, - external_issuer=actor.external_issuer, - email=actor.email, - display_name=actor.display_name, - roles=actor.roles, - auth_source=actor.auth_source, - is_dev_auth=actor.is_dev_auth, - audit_context=actor.audit_context(), - ) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 063f7b9c2..5c096b906 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -645,6 +645,7 @@ def guide_payload(run_id: str) -> dict: return { "version": "v1", "change_summary": "Initial real API guide", + "documents": [{"label": f"guide-{run_id}.pdf", "media_type": "application/pdf"}], "task_examples": [ {"content": "Review a claim using the project guide."}, {"content": "Explain how a second claim should be handled.", "title": "Second example"}, @@ -780,6 +781,7 @@ async def exercise_guide_setup_contract( guide_id: str, run_id: str, *, + documents: list[dict], task_fixture: bool = False, ) -> dict: """Prove unified draft output, reads and manual policy authority through HTTP. @@ -799,38 +801,20 @@ async def exercise_guide_setup_contract( Returns: Effective project submission artifact policy response. """ - snapshot = await request_json( - client, - "POST", - f"/api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots", - diagnostic_reader_token, - { - "items": [ - { - "source_kind": "document", - "source_label": f"guide-{run_id}.pdf", - "ingestion_adapter": "upload", - "media_type": "application/pdf", - } - ] - }, - 201, - idempotency_key=str(uuid4()), - ) - for item in snapshot["items"]: + for document in documents: from guide_compilation_e2e import guide_pdf_bytes payload = guide_pdf_bytes() upload = await client.post( - f"/api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots/" - f"{snapshot['id']}/items/{item['id']}/artifact", + f"/api/v1/projects/{project_id}/guides/{guide_id}/documents/" + f"{document['document_id']}/content", headers={ "Authorization": f"Bearer {diagnostic_reader_token}", "Idempotency-Key": str(uuid4()), - "Content-Type": item["media_type"] or "application/octet-stream", + "Content-Type": document["media_type"], }, content=payload, ) - ensure(upload.status_code == 202, f"guide source upload failed: {upload.text}") + ensure(upload.status_code == 202, f"guide document upload failed: {upload.text}") # Successful original uploads already commit document readiness. The guide # has no verifier/extractor work to drain. Deliver its queued compilation # below through the explicit scripted runtime used by this API drill. @@ -840,6 +824,9 @@ async def exercise_guide_setup_contract( f"/api/v1/projects/{project_id}/guides/{guide_id}/setup-runs/latest", diagnostic_reader_token, ) + # Internal lineage for the explicitly separate downstream fixture below; + # the client upload flow uses only document IDs returned by guide creation. + snapshot = {"id": queued_setup["source_snapshot_id"]} ensure(queued_setup["documents_ready_at"] is not None, "guide originals are not committed") from app.modules.projects.api.setup_identity import project_guide_compilation_task_id @@ -899,6 +886,7 @@ async def exercise_guide_setup_contract( f"{setup_run['output_sufficiency_report_id']}", diagnostic_reader_token, ) + snapshot["bundle_hash"] = report["source_snapshot_hash"] reports = await request_json( client, "GET", @@ -1386,21 +1374,16 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: ] == "project_role_grant.revoke" ) - await request_json(client, "GET", "/api/v1/auth/me", expected_status=401) - await request_json(client, "GET", "/api/v1/auth/me", invalid_token, expected_status=401) + await request_json(client, "GET", "/api/v1/actors/me", expected_status=401) + await request_json(client, "GET", "/api/v1/actors/me", invalid_token, expected_status=401) await request_json( - client, "GET", "/api/v1/auth/me", wrong_issuer_token, expected_status=401 + client, "GET", "/api/v1/actors/me", wrong_issuer_token, expected_status=401 ) await request_json( - client, "GET", "/api/v1/auth/me", wrong_audience_token, expected_status=401 + client, "GET", "/api/v1/actors/me", wrong_audience_token, expected_status=401 ) - await request_json(client, "GET", "/api/v1/auth/me", expired_token, expected_status=401) - await request_json(client, "GET", "/api/v1/auth/me", future_nbf_token, expected_status=401) - manager = await request_json(client, "GET", "/api/v1/auth/me", manager_token) - assert manager["auth_source"] == "flow" - assert manager["is_dev_auth"] is False - assert manager["roles"] == ["project_manager"] - + await request_json(client, "GET", "/api/v1/actors/me", expired_token, expected_status=401) + await request_json(client, "GET", "/api/v1/actors/me", future_nbf_token, expected_status=401) manager_profile = await request_json( client, "GET", @@ -1779,6 +1762,7 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: project["id"], guide["id"], run_id, + documents=guide["documents"], ) await request_json( client, @@ -1817,6 +1801,7 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: project["id"], guide["id"], run_id, + documents=guide["documents"], task_fixture=True, ) active = await seed_active_guide_for_pre_12h_e2e( @@ -1930,8 +1915,6 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: {"reason": "real API release"}, ) - worker = await request_json(client, "GET", "/api/v1/auth/me", worker_token) - assert worker["roles"] == ["worker"] canonical_actor = await request_json( client, "GET", diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index acdc0a84f..dbc835c17 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -249,6 +249,10 @@ 'backend/scripts/guide_compilation_e2e.py', }) +POL_04B2_PARTITION_TARGETS = frozenset({ + "backend/app/modules/projects/document_upload.py", +}) + POL_04B_REMOVED_TARGETS = frozenset({ 'backend/app/modules/artifacts/guide_bindings.py', 'backend/app/modules/artifacts/guide_docx.py', @@ -478,6 +482,7 @@ def _validate_additive_partition_transition( | POL_04A3_PARTITION_TARGETS | POL_04B1_PARTITION_TARGETS | POL_04B_PARTITION_TARGETS + | POL_04B2_PARTITION_TARGETS | API_DRILL_PARTITION_TARGETS | AUTH_12I_TARGETS diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index 8da3a16a2..236003890 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -201,6 +201,7 @@ class TestLane: "tests/projects/guide_compilation/finalization/test_authorization_postgresql.py", "tests/projects/test_active_guide_repository.py", + "tests/test_guide_document_intake.py", "tests/projects/guide_compilation/finalization/test_concurrency_postgresql.py", "tests/projects/guide_compilation/finalization/test_contracts.py", "tests/projects/guide_compilation/finalization/test_guards_postgresql.py", diff --git a/backend/tests/authentication/test_admission.py b/backend/tests/authentication/test_admission.py index 20a21d3d3..a6b0038a4 100644 --- a/backend/tests/authentication/test_admission.py +++ b/backend/tests/authentication/test_admission.py @@ -7,6 +7,7 @@ AsyncClient, ) +from app.core.config import Settings from app.main import create_app @@ -17,7 +18,7 @@ async def test_missing_bearer_token_is_rejected() -> None: transport=ASGITransport(app=app), base_url="http://testserver", ) as client: - response = await client.get("/api/v1/auth/me") + response = await client.get("/api/v1/actors/me") assert response.status_code == 401 assert response.json()["detail"] == "Missing bearer token" @@ -44,7 +45,7 @@ async def test_invalid_bearer_token_is_rejected(monkeypatch: pytest.MonkeyPatch) base_url="http://testserver", ) as client: response = await client.get( - "/api/v1/auth/me", + "/api/v1/actors/me", headers={"Authorization": "Bearer wrong-token"}, ) @@ -55,14 +56,20 @@ async def test_invalid_bearer_token_is_rejected(monkeypatch: pytest.MonkeyPatch) async def test_invalid_production_verifier_configuration_is_service_unavailable() -> None: - app = create_app() + app = create_app(Settings( + _env_file=None, + environment="production", + auth_provider="flow", + flow_auth_local_hmac_secret=None, + token_issuer=None, + )) async with AsyncClient( transport=ASGITransport(app=app), base_url="http://testserver", ) as client: response = await client.get( - "/api/v1/auth/me", + "/api/v1/actors/me", headers={"Authorization": "Bearer opaque-token"}, ) diff --git a/backend/tests/authentication/test_subject_authority.py b/backend/tests/authentication/test_subject_authority.py index 5f449bc5c..e65de83f7 100644 --- a/backend/tests/authentication/test_subject_authority.py +++ b/backend/tests/authentication/test_subject_authority.py @@ -49,7 +49,7 @@ async def no_database_session() -> AsyncIterator[None]: transport=ASGITransport(app=app), base_url="http://testserver" ) as client: response = await client.get( - "/api/v1/auth/me", headers={"Authorization": f"Bearer {agent_token}"} + "/api/v1/actors/me", headers={"Authorization": f"Bearer {agent_token}"} ) assert response.status_code == 403 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 942b02181..d7b4201ad 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "719fbcf4e617bf14b145e9ed67789de98ec349ee96af07154872ad62c261a44c" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "0b25b6c185f496e9733d046c06c0b6ef2b33d12e7e0737ea5d79fc0652c90c43" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/project_create_fixtures.py b/backend/tests/project_create_fixtures.py index 0d47a8c2f..674f856fa 100644 --- a/backend/tests/project_create_fixtures.py +++ b/backend/tests/project_create_fixtures.py @@ -66,6 +66,16 @@ async def seed_guide_snapshot_rows(connection, *, project_id: str, guide_id: str ), params) +# Creation-only exclusions for downstream fixtures; live guide tests never use these. +GUIDE_CREATION_CUSTODY_TRIGGERS = ( + ("project_guides", "guide_mutation_product_custody"), + ("project_guides", "guide_task_examples_create_custody"), + ("project_guides", "require_document_creation_pair"), + ("guide_source_snapshots", "source_snapshot_product_custody"), + ("guide_source_snapshots", "require_document_creation_pair"), +) + + _ISOLATED_DATABASE_RE = re.compile(r"workstream_test_([a-f0-9]{12})") _ISOLATED_ROLE_RE = re.compile(r"workstream_role_([a-f0-9]{12})") @@ -82,9 +92,10 @@ async def suspend_historical_product_custody( "project_guides": { "guide_mutation_product_custody", "guide_task_examples_create_custody", + "require_document_creation_pair", "guide_lineage_lifecycle_guard", }, - "guide_source_snapshots": {"source_snapshot_product_custody"}, + "guide_source_snapshots": {"source_snapshot_product_custody", "require_document_creation_pair"}, "guide_source_snapshot_items": {"guide_source_snapshot_items_custody"}, "project_setup_runs": {"source_setup_run_custody"}, "review_policies": {"review_policy_mutation_custody"}, diff --git a/backend/tests/projects/client_fixtures.py b/backend/tests/projects/client_fixtures.py index 0fb894665..28f7561d9 100644 --- a/backend/tests/projects/client_fixtures.py +++ b/backend/tests/projects/client_fixtures.py @@ -55,7 +55,7 @@ async def project_client(project_database_env: str) -> AsyncIterator[AsyncClient transport=ASGITransport(app=app), base_url="http://testserver", ) as client: - admission = await client.get("/api/v1/auth/me", headers=auth_headers()) + admission = await client.get("/api/v1/actors/me", headers=auth_headers()) assert admission.status_code == 200, admission.text actor_id, _link_id, grantor_id = await ensure_access_administrator_bootstrap() async with db_session.get_session_factory()() as session: diff --git a/backend/tests/projects/guide_compilation/test_automatic_request.py b/backend/tests/projects/guide_compilation/test_automatic_request.py index 02e86fa7c..4466df588 100644 --- a/backend/tests/projects/guide_compilation/test_automatic_request.py +++ b/backend/tests/projects/guide_compilation/test_automatic_request.py @@ -28,7 +28,7 @@ from tests.projects.guide_fixtures import ( create_project, create_guide, - create_source_snapshot, + read_guide_source_snapshot, complete_guide_payload, ) from tests.committed_guide_fixtures import create_committed_document_fixture @@ -40,7 +40,7 @@ async def automatic_source(project_client, project_database_env, monkeypatch): get_settings.cache_clear() project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) engine = create_async_engine(project_database_env) factory = async_sessionmaker(engine, expire_on_commit=False) async with factory() as session: @@ -368,7 +368,7 @@ async def test_original_manager_revocation_does_not_rewrite_source_consent( @pytest.mark.asyncio @pytest.mark.postgres_schema_contract -async def test_retained_automatic_evidence_prevents_configuration_downgrade( +async def test_retained_automatic_evidence_prevents_guide_creation_downgrade( automatic_source, migration_lock ): import asyncio @@ -382,12 +382,15 @@ async def test_retained_automatic_evidence_prevents_configuration_downgrade( actor=actor, setup_run_id=setup_id ) + async with factory() as session: + assert await session.scalar(text("select version_num from alembic_version")) == current_schema_revision() + def downgrade(): with migration_lock(): command.downgrade(Config("alembic.ini"), "0012_contribution_policy_audit_resource") with pytest.raises( - RuntimeError, match="guide document runtime downgrade would discard retained evidence" + RuntimeError, match="guide document creation custody cannot be downgraded" ): await asyncio.to_thread(downgrade) async with factory() as session: @@ -536,9 +539,7 @@ async def test_stored_foreign_source_is_rejected_by_repository_and_insert( factory, actor, setup_id, snapshot = automatic_source other_project = await create_project(project_client) other_guide = await create_guide(project_client, other_project["id"], complete_guide_payload()) - other_snapshot = await create_source_snapshot( - project_client, other_project["id"], other_guide["id"] - ) + other_snapshot = await read_guide_source_snapshot(other_project["id"], other_guide["id"]) await create_committed_document_fixture(snapshot["id"]) await create_committed_document_fixture(other_snapshot["id"]) async with factory() as session, session.begin(): @@ -677,40 +678,6 @@ async def test_direct_insert_checks_exact_authority_digest(automatic_source, eve ) -@pytest.mark.asyncio -async def test_new_source_invalidates_unrequested_older_source(automatic_source, project_client): # noqa: F811 - from app.modules.projects.guide_compilation.repository import ( - GuideCompilationIntegrityError, - GuideCompilationRepository, - ) - - factory, actor, setup_id, snapshot = automatic_source - await create_committed_document_fixture(snapshot["id"]) - async with factory() as session, session.begin(): - facts, _, origin = await automatic_service(session, actor)._automatic_inputs.resolve( - session, setup_id - ) - newer = await create_source_snapshot(project_client, str(facts.project_id), str(facts.guide_id)) - assert newer["id"] != snapshot["id"] - async with factory() as session: - with pytest.raises(GuideCompilationIntegrityError, match="origin unavailable"): - async with session.begin(): - await GuideCompilationRepository(session).require_automatic_request_origin( - facts, origin - ) - for table in ( - "project_guide_compilation_request_operations", - "project_guide_compilation_attempts", - ): - assert await session.scalar(text(f"select count(*) from {table}")) == 0 - assert ( - await session.scalar( - text( - "select count(*) from audit_events where action_id='project.guide_compilation.request_automatic'" - ) - ) - == 0 - ) @pytest.mark.asyncio diff --git a/backend/tests/projects/guide_compilation/test_task_examples_postgresql.py b/backend/tests/projects/guide_compilation/test_task_examples_postgresql.py index 1c198ebd5..747082546 100644 --- a/backend/tests/projects/guide_compilation/test_task_examples_postgresql.py +++ b/backend/tests/projects/guide_compilation/test_task_examples_postgresql.py @@ -16,14 +16,14 @@ project_client as project_client, project_database_env as project_database_env, ) -from tests.projects.guide_fixtures import create_project, create_source_snapshot, create_guide, complete_guide_payload +from tests.projects.guide_fixtures import create_project, read_guide_source_snapshot, create_guide, complete_guide_payload async def _guide(client, project_id, examples, *, key=None): return await client.post( f"/api/v1/projects/{project_id}/guides", headers=auth_headers() | {"Idempotency-Key": key or str(uuid4())}, - json={"version": "example-proof", "task_examples": examples}, + json={"version": "example-proof", "task_examples": examples, "documents": complete_guide_payload()["documents"]}, ) @@ -32,7 +32,7 @@ async def test_required_examples_reject_before_product_effects_then_exact_replay route = f"/api/v1/projects/{project['id']}/guides" for patch in [{}, {"task_examples": []}, {"task_examples": [{"content": " \t\u2003"}]}]: response = await project_client.post( - route, headers=auth_headers(), json={"version": "example-proof", **patch}, + route, headers=auth_headers(), json={"version": "example-proof", "documents": complete_guide_payload()["documents"], **patch}, ) assert response.status_code == 422 async with get_session_factory()() as session: @@ -53,7 +53,7 @@ async def test_required_examples_reject_before_product_effects_then_exact_replay [examples[0], examples[1] | {"labels": ["changed"]}]]: response = await _guide(project_client, project["id"], changed, key=key) assert response.status_code == 409, response.text - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) assert snapshot["manifest_json"]["task_examples_hash"] == guide["task_examples_hash"] assert snapshot["manifest_json"]["task_examples_count"] == 2 async with get_session_factory()() as session: @@ -103,7 +103,7 @@ async def test_same_example_hash_cannot_cross_compose_setup_ownership(project_cl guide = response.json() projects.append(project) guides.append(guide) - snapshots.append(await create_source_snapshot(project_client, project["id"], guide["id"])) + snapshots.append(await read_guide_source_snapshot(project["id"], guide["id"])) assert guides[0]["task_examples_hash"] == guides[1]["task_examples_hash"] async with get_session_factory()() as session, session.begin(): with pytest.raises(DBAPIError, match="guide setup snapshot ownership mismatch"): @@ -121,7 +121,7 @@ async def test_source_snapshot_hash_is_server_computed_and_canonical( project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) expected_manifest = { "schema_version": "guide_source_snapshot.task_examples", "task_examples_hash": guide["task_examples_hash"], @@ -158,7 +158,7 @@ async def test_retained_missing_examples_are_visible_and_never_invoke_provider(p response = await _guide(project_client, project["id"], [{"content": "Review one claim."}]) assert response.status_code == 201, response.text guide = response.json() - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) async with get_session_factory()() as session, session.begin(): setup = (await session.execute(text( "select id,setup_generation from project_setup_runs where source_snapshot_id=:id" @@ -192,12 +192,6 @@ def forbid_provider(configuration): assert diagnostic.status_code == 200, diagnostic.text assert diagnostic.json()["status"] == "setup_input_invalid" assert diagnostic.json()["error_code"] == "task_examples_missing" - new_snapshot = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", - headers=auth_headers(), json={"items": [{"source_kind": "document", "source_label": "new.pdf", "ingestion_adapter": "upload", "media_type": "application/pdf"}]}, - ) - assert new_snapshot.status_code == 422, new_snapshot.text - assert new_snapshot.json()["detail"] == "task_examples_missing" async with get_session_factory()() as session: assert await session.scalar(text("select count(*) from project_guide_compilation_attempts")) == 0 assert await session.scalar(text("select count(*) from guide_source_snapshots where guide_id=:id"), {"id": guide["id"]}) == 1 diff --git a/backend/tests/projects/guide_creation_sql.py b/backend/tests/projects/guide_creation_sql.py new file mode 100644 index 000000000..5105cdc8f --- /dev/null +++ b/backend/tests/projects/guide_creation_sql.py @@ -0,0 +1,158 @@ +"""Raw SQL creation graphs derived from a fully authorized, rolled-back control.""" + +import copy +import json +from uuid import uuid4 + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash + +TABLES = ( + "audit_events", "guide_mutation_idempotency_records", "project_guides", + "guide_source_snapshots", "guide_source_snapshot_items", "project_setup_runs", +) + + +async def capture_creation_graph(client, project_id, headers, monkeypatch): + """Obtain exact valid rows, then roll them back before raw-SQL proof begins.""" + graph = {} + original_commit = AsyncSession.commit + + async def capture(session): + await session.flush() + guides = (await session.execute(text("select * from project_guides"))).mappings().all() + if not guides: + return await original_commit(session) + assert len(guides) == 1 + for table in TABLES: + graph[table] = [dict(row) for row in ( + await session.execute(text(f"select * from {table}")) + ).mappings()] + event_ids = {guides[0]["last_authorization_decision_event_id"]} + event_ids.update(row["authorization_decision_event_id"] for row in graph["guide_source_snapshots"]) + graph["audit_events"] = [row for row in graph["audit_events"] if row["id"] in event_ids] + assert len(graph["audit_events"]) == 2 + await session.rollback() + + with monkeypatch.context() as patch: + patch.setattr(AsyncSession, "commit", capture) + response = await client.post( + f"/api/v1/projects/{project_id}/guides", headers=headers, + json={"version": "raw-control", "task_examples": [{"content": "Review a claim."}], + "documents": [{"label": "guide.pdf", "media_type": "application/pdf"}]}, + ) + assert response.status_code == 201, response.text + assert set(graph) == set(TABLES) + return graph + + +async def insert_graph(connection, graph): + """Bypass product services and ORM writes while retaining every database guard.""" + for table in TABLES: + for row in graph[table]: + value = dict(row) + if table == "guide_mutation_idempotency_records": + value.update(status="pending", response_json=None, setup_run_id=None, committed_at=None) + await connection.execute(text( + f"insert into {table} select * from jsonb_populate_record(null::{table}, cast(:row as jsonb))" + ), {"row": json.dumps(value, default=str)}) + for row in graph["guide_mutation_idempotency_records"]: + await connection.execute(text(""" + update guide_mutation_idempotency_records target + set status=source.status, response_json=source.response_json, + setup_run_id=source.setup_run_id, committed_at=source.committed_at + from jsonb_populate_record(null::guide_mutation_idempotency_records, cast(:row as jsonb)) source + where target.id=source.id + """), {"row": json.dumps(row, default=str)}) + + +def malformed_graph(control, fault): + graph = copy.deepcopy(control) + if fault == "missing_source": + graph["guide_mutation_idempotency_records"] = [row for row in graph["guide_mutation_idempotency_records"] + if row["action_id"] == "project.guide.create"] + for table in ("guide_source_snapshots", "guide_source_snapshot_items", "project_setup_runs"): + graph[table] = [] + elif fault == "cross_key": + for row in graph["guide_mutation_idempotency_records"]: + if row["action_id"] == "project.guide_source_snapshot.create": + row["idempotency_key"] = uuid4() + elif fault in {"setup_status", "setup_step", "setup_ready_at", "setup_celery"}: + fields = {"setup_status": ("status", "queued"), + "setup_step": ("current_step", "queued"), + "setup_ready_at": ("documents_ready_at", "2026-09-11T00:00:00+00:00"), + "setup_celery": ("celery_task_id", str(uuid4()))} + field, value = fields[fault] + graph["project_setup_runs"][0][field] = value + return graph + + +def second_source_graph(control): + """Build another fully attributed set for the same guide, with fresh evidence.""" + graph = copy.deepcopy(control) + graph["project_guides"] = [] + graph["guide_mutation_idempotency_records"] = [row for row in graph["guide_mutation_idempotency_records"] + if row["action_id"] == "project.guide_source_snapshot.create"] + source = graph["guide_source_snapshots"][0] + graph["audit_events"] = [row for row in graph["audit_events"] if row["id"] == source["authorization_decision_event_id"]] + replacements = {str(row["id"]): str(uuid4()) for rows in graph.values() for row in rows} + ledger = graph["guide_mutation_idempotency_records"][0] + replacements[str(ledger["operation_id"])] = str(uuid4()) + replacements[str(ledger["idempotency_key"])] = str(uuid4()) + serialized = json.dumps(graph, default=str) + for old, new in replacements.items(): + serialized = serialized.replace(old, new) + graph = json.loads(serialized) + source = graph["guide_source_snapshots"][0] + ledger = graph["guide_mutation_idempotency_records"][0] + setup = graph["project_setup_runs"][0] + old_hash = source["bundle_hash"] + new_hash = canonical_json_hash(source["manifest_json"]) + graph = json.loads(json.dumps(graph).replace(old_hash, new_hash)) + source = graph["guide_source_snapshots"][0] + ledger = graph["guide_mutation_idempotency_records"][0] + setup = graph["project_setup_runs"][0] + setup["setup_generation"] = 2 + from app.modules.projects.guide_mutation_service import GuideMutationService + from uuid import UUID + resource = GuideMutationService._source_resource( + UUID(source["project_id"]), UUID(source["guide_id"]), source["guide_version"], + UUID(source["id"]), source["bundle_hash"], UUID(ledger["operation_id"]), + ) + digest = canonical_json_hash(resource.model_dump(mode="json")) + ledger["resource_context_digest"] = digest + graph["audit_events"][0]["after_facts"]["resource_context_digest"] = digest + return graph + + +async def install_predicate_mutant(connection, fault): + definition = await connection.scalar(text( + "select pg_get_functiondef('require_guide_document_creation_pair()'::regprocedure)")) + if fault.startswith("setup_"): + predicates = { + "setup_status": "setup_row.status IS DISTINCT FROM 'awaiting_documents'", + "setup_step": "setup_row.current_step IS DISTINCT FROM 'awaiting_documents'", + "setup_ready_at": "setup_row.documents_ready_at IS NOT NULL", + "setup_celery": "setup_row.celery_task_id IS NOT NULL", + } + anchor = " OR " + predicates[fault] + "\n" + assert definition.count(anchor) == 1 + definition = definition.replace(anchor, "") + elif fault == "cross_key": + old = "root_row.actor_profile_id,root_row.identity_link_id,root_row.project_id,root_row.idempotency_key" + new = "root_row.actor_profile_id,root_row.identity_link_id,root_row.project_id" + assert definition.count(old) == 1 + definition = definition.replace(old, new) + old = "source_row.actor_profile_id,source_row.identity_link_id,source_row.project_id,source_row.idempotency_key" + assert definition.count(old) == 1 + definition = definition.replace(old, "source_row.actor_profile_id,source_row.identity_link_id,source_row.project_id") + else: + anchor = " IF guide_row.id IS NULL OR root_row.id IS NULL OR source_row.id IS NULL" + assert definition.count(anchor) == 1 + condition = ("snapshot_row.id IS NULL AND source_row.id IS NULL AND setup_row.id IS NULL" + if fault == "missing_source" else + "(SELECT count(*) FROM guide_source_snapshots WHERE guide_id=guide_key) > 1") + definition = definition.replace(anchor, f" IF {condition} THEN RETURN NULL; END IF;\n" + anchor) + await connection.execute(text(definition)) diff --git a/backend/tests/projects/guide_fixtures.py b/backend/tests/projects/guide_fixtures.py index f39a2e5fd..700834ef9 100644 --- a/backend/tests/projects/guide_fixtures.py +++ b/backend/tests/projects/guide_fixtures.py @@ -18,6 +18,8 @@ def complete_guide_payload(version: str = "v1") -> dict: "version": version, "change_summary": f"Initial {version}", "task_examples": [{"content": "Review a claim using the project guide."}], + "documents": [{"label": name, "media_type": "application/pdf"} + for name in ("guide.pdf", "rubric.pdf")], } @@ -67,43 +69,27 @@ async def add_project_manager_admin_grant(project_id: str) -> UUID: return grant.id -def source_snapshot_payload(*, source_label: str = "guide.pdf") -> dict: - return { - "items": [ - { - "source_kind": "document", - "source_label": source_label, - "ingestion_adapter": "upload", - "media_type": "application/pdf", - }, - { - "source_kind": "document", - "source_label": "rubric.pdf", - "ingestion_adapter": "upload", - "media_type": "application/pdf", - }, - ] - } - +async def read_guide_source_snapshot(project_id: str, guide_id: str) -> dict: + """Read internal creation lineage for downstream policy/history tests.""" + from app.modules.projects.repository import ProjectRepository + from app.modules.projects.schemas import GuideSourceSnapshotResponse, GuideSourceSnapshotItemResponse + from app.modules.projects.models import ProjectGuide -async def create_source_snapshot( - client: AsyncClient, - project_id: str, - guide_id: str, - payload: dict | None = None, -) -> dict: - response = await client.post( - f"/api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots", - headers=auth_headers(), - json=payload if payload is not None else source_snapshot_payload(), - ) - assert response.status_code == 201, response.text - return response.json() + async with db_session.get_session_factory()() as session: + guide = await session.get(ProjectGuide, guide_id) + assert guide is not None and guide.project_id == project_id + repository = ProjectRepository(session) + snapshot = await repository.get_latest_guide_source_snapshot(project_id, guide_id, guide.version) + assert snapshot is not None + items = await repository.list_guide_source_snapshot_items(snapshot.id) + response = GuideSourceSnapshotResponse.model_validate(snapshot) + response.items = [GuideSourceSnapshotItemResponse.model_validate(item) for item in items] + return response.model_dump(mode="json") async def create_guide(client: AsyncClient, project_id: str, payload: dict) -> dict: request_payload = dict(payload) - source_snapshot = request_payload.pop("source_snapshot", None) + request_payload.setdefault("documents", complete_guide_payload()["documents"]) review_policy = request_payload.pop("review_policy", "default") revision_policy = request_payload.pop("revision_policy", "default") payment_policy = request_payload.pop("payment_policy", "default") @@ -182,6 +168,4 @@ async def create_guide(client: AsyncClient, project_id: str, payload: dict) -> d ) await session.commit() await add_project_manager_admin_grant(project_id) - if source_snapshot is not None: - await create_source_snapshot(client, project_id, guide["id"], source_snapshot) return guide diff --git a/backend/tests/projects/policy_bundle_fixtures.py b/backend/tests/projects/policy_bundle_fixtures.py index 243c723b4..33e11a3a7 100644 --- a/backend/tests/projects/policy_bundle_fixtures.py +++ b/backend/tests/projects/policy_bundle_fixtures.py @@ -4,7 +4,7 @@ from httpx import AsyncClient -from projects.guide_fixtures import create_source_snapshot +from projects.guide_fixtures import read_guide_source_snapshot from projects.post_submit_fixtures import ( seed_post_submit_policy_for_downstream_tests, ) @@ -26,7 +26,7 @@ async def create_approved_policy_bundle( sufficiency_status: str = "passed", compile_pre_submit_checker: bool = True, ) -> dict: - snapshot = await create_source_snapshot(client, project_id, guide_id) + snapshot = await read_guide_source_snapshot(project_id, guide_id) report = await create_sufficiency_report( client, project_id, diff --git a/backend/tests/projects/sufficiency_mutations/test_acknowledgement_postgresql.py b/backend/tests/projects/sufficiency_mutations/test_acknowledgement_postgresql.py index 7b7c23efd..91e372922 100644 --- a/backend/tests/projects/sufficiency_mutations/test_acknowledgement_postgresql.py +++ b/backend/tests/projects/sufficiency_mutations/test_acknowledgement_postgresql.py @@ -24,7 +24,7 @@ complete_guide_payload, create_guide, create_project, - create_source_snapshot, + read_guide_source_snapshot, ) from projects.submission_policy_fixtures import create_sufficiency_report from committed_guide_fixtures import create_compiled_report_fixture @@ -81,7 +81,7 @@ async def transaction_state(session, report_id, project_id, key): async def test_acknowledgement_late_conflict_rolls_back(project_client, monkeypatch): project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) diagnostic = await create_sufficiency_report( project_client, project["id"], diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index c1a5b1a56..1efba4bc1 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -86,6 +86,7 @@ def test_v01_graph_has_one_root_and_head() -> None: assert [revision.revision for revision in revisions] == [ HEAD_REVISION, + "0017_task_project_authority", "0016_guide_document_runtime", "0015_guide_runtime_configuration", "0014_project_role_scope", @@ -551,13 +552,24 @@ def test_root_upgrade_refuses_nonempty_unstamped_schema_before_product_ddl( assert ("r", "projects") not in snapshot["objects"] -def test_root_downgrade_refuses_without_mutation( +@pytest.mark.parametrize("revision,message", [ + (BASELINE_REVISION, "0001_v01_baseline cannot be downgraded; recreate the database"), + (HEAD_REVISION, "guide document creation custody cannot be downgraded"), +]) +def test_downgrade_refuses_without_mutation( isolated_database_env: str, migration_lock, + revision: str, + message: str, ) -> None: config = _alembic_config() + with migration_lock(): + asyncio.run(_execute(isolated_database_env, "drop schema public cascade; create schema public")) + command.upgrade(config, revision) + command.upgrade(config, revision) # Current installed revision remains a valid no-op target. before = asyncio.run(_database_snapshot(isolated_database_env)) - with migration_lock(), pytest.raises(RuntimeError, match="guide document runtime downgrade would discard retained evidence"): + assert before["versions"] == [revision] + with migration_lock(), pytest.raises(RuntimeError, match=message): command.downgrade(config, "base") after = asyncio.run(_database_snapshot(isolated_database_env)) assert before == after @@ -575,3 +587,31 @@ async def read_next_values() -> tuple[int, int]: await connection.close() assert asyncio.run(read_next_values()) == (2, 2) + + +def test_immediate_predecessor_upgrades_to_current_guide_creation_custody( + isolated_database_env: str, + migration_lock, + migration_schema_at, +) -> None: + with migration_lock(): + migration_schema_at("0017_task_project_authority") + before = asyncio.run(_database_snapshot(isolated_database_env)) + assert before["versions"] == ["0017_task_project_authority"] + with migration_lock(): + command.upgrade(_alembic_config(), HEAD_REVISION) + after = asyncio.run(_database_snapshot(isolated_database_env)) + assert after["versions"] == [HEAD_REVISION] + assert after["reference_rows"] == before["reference_rows"] + + async def creation_guards() -> int: + connection = await asyncpg.connect(isolated_database_env.replace("+asyncpg", "")) + try: + return await connection.fetchval( + "select count(*) from pg_trigger where tgname='require_document_creation_pair' " + "and not tgisinternal and tgdeferrable and tginitdeferred" + ) + finally: + await connection.close() + + assert asyncio.run(creation_guards()) == 3 diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index 19b71881e..38245ea51 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -437,13 +437,13 @@ def test_openapi_documents_request_error_and_response_context() -> None: for method, operation in path_item.items() if method in methods and operation.get("security") ) - assert len(route_inventory) == 73 + assert len(route_inventory) == 72 assert sha256("\n".join(route_inventory).encode()).hexdigest() == ( - "23b90006282444310fe13a34fab947c8cff7284b288a2fe26e2ddb9780f8691f" + "467431130a55743092c60339f318af93c526b0e8ccbdb2a2690637b4cb497cd1" ) - assert len(protected_inventory) == 71 + assert len(protected_inventory) == 70 assert sha256("\n".join(protected_inventory).encode()).hexdigest() == ( - "286149aa75927259d4659eee97400b53e41623ae234a3cf1f5657647edfd269d" + "bd581252ecceda632de3bc8696a6b70e2ff15d2e0c519703138ef9d83c7f1191" ) assert "/api/v1/workers/me/profile" not in schema["paths"] assert "post" not in schema["paths"]["/api/v1/tasks/{task_id}/submissions"] @@ -451,7 +451,7 @@ def test_openapi_documents_request_error_and_response_context() -> None: assert "POST /api/v1/operations/tasks/{task_id}/start" in protected_inventory assert "GET /api/v1/projects/{project_id}/tasks/{task_id}/work-context" in protected_inventory assert set(schema["paths"]["/health"]["get"]["responses"]) == {"200", "400", "500"} - assert {"401", "403", "503"} <= set(schema["paths"]["/api/v1/auth/me"]["get"]["responses"]) + assert {"401", "403", "503"} <= set(schema["paths"]["/api/v1/actors/me"]["get"]["responses"]) service_actor_responses = schema["paths"]["/api/v1/service-actors"]["post"]["responses"] assert "409" in service_actor_responses assert service_actor_responses["409"]["content"]["application/json"]["schema"] == { @@ -519,8 +519,8 @@ def test_openapi_documents_request_error_and_response_context() -> None: "PUT /api/v1/projects/{project_id}/guides/{guide_id}/revision-policy": ( "project.revision_policy.update" ), - "POST /api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots": ( - "project.guide_source_snapshot.create" + "POST /api/v1/projects/{project_id}/guides/{guide_id}/documents/{document_id}/content": ( + "artifact.guide_source.ingest" ), "POST /api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports": ( "project.guide_sufficiency_report.create" @@ -613,7 +613,7 @@ def test_openapi_documents_request_error_and_response_context() -> None: ("/api/v1/projects/{project_id}/guides", "post"), ("/api/v1/projects/{project_id}/guides/{guide_id}", "patch"), ( - "/api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots", + "/api/v1/projects/{project_id}/guides/{guide_id}/documents/{document_id}/content", "post", ), ): diff --git a/backend/tests/test_api_drill_repairs.py b/backend/tests/test_api_drill_repairs.py index 91114bb1a..b3c487ac9 100644 --- a/backend/tests/test_api_drill_repairs.py +++ b/backend/tests/test_api_drill_repairs.py @@ -397,9 +397,9 @@ async def test_unsupported_adjudicator_role_is_rejected_without_grant( monkeypatch.setenv("WORKSTREAM_DEV_AUTH_SUBJECT", target_subject) monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ROLES", "contributor") get_settings.cache_clear() - target_admission = await project_client.get("/api/v1/auth/me", headers=auth_headers()) + target_admission = await project_client.get("/api/v1/actors/me", headers=auth_headers()) assert target_admission.status_code == 200, target_admission.text - target_actor_id = target_admission.json()["actor_id"] + target_actor_id = target_admission.json()["actor_profile_id"] monkeypatch.setenv("WORKSTREAM_DEV_AUTH_SUBJECT", "project-manager-subject") monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ROLES", "project_manager") diff --git a/backend/tests/test_artifact_admission.py b/backend/tests/test_artifact_admission.py index 93f6b2f84..d2fed6535 100644 --- a/backend/tests/test_artifact_admission.py +++ b/backend/tests/test_artifact_admission.py @@ -4,7 +4,7 @@ from __future__ import annotations -from project_create_fixtures import guide_example_columns, guide_snapshot_columns +from project_create_fixtures import guide_example_columns, guide_snapshot_columns, seed_guide_snapshot_rows import asyncio from collections.abc import Iterator @@ -64,6 +64,7 @@ GuideArtifactAdmissionRequest, ) from app.modules.artifacts.service import ( + ArtifactAdmissionConflictError, ArtifactAdmissionRelationshipError, ArtifactAdmissionService, ArtifactStorageNamespaceSpec, @@ -288,7 +289,7 @@ async def _seed_guide( async with suspend_historical_product_custody( session, table="project_guides", - triggers=("guide_mutation_product_custody", "guide_task_examples_create_custody"), + triggers=("guide_mutation_product_custody", "guide_task_examples_create_custody", "require_document_creation_pair"), ): session.add( ProjectGuide( @@ -304,7 +305,7 @@ async def _seed_guide( async with suspend_historical_product_custody( session, table="guide_source_snapshots", - triggers=("source_snapshot_product_custody",), + triggers=("source_snapshot_product_custody", "require_document_creation_pair"), ): session.add( GuideSourceSnapshot( @@ -390,38 +391,18 @@ async def _seed_checker_output_relationships(session) -> tuple[str, str, str]: ) await session.flush() async with suspend_historical_product_custody( - session, - table="project_guides", - triggers=("guide_mutation_product_custody", "guide_task_examples_create_custody"), + session, table="project_guides", + triggers=("guide_mutation_product_custody", "guide_task_examples_create_custody", "require_document_creation_pair"), + ), suspend_historical_product_custody( + session, table="guide_source_snapshots", + triggers=("source_snapshot_product_custody", "require_document_creation_pair"), ): - session.add( - ProjectGuide( - **guide_example_columns(), - id=guide_id, - project_id=project_id, - version=guide_version, - status="draft", - approved_by="setup-actor", - effective_at=now, - created_by="setup-actor", - ) - ) - await session.flush() - async with suspend_historical_product_custody( - session, - table="guide_source_snapshots", - triggers=("source_snapshot_product_custody",), - ): - session.add( - GuideSourceSnapshot( - id=snapshot_id, - project_id=project_id, - guide_id=guide_id, - guide_version=guide_version, - **guide_snapshot_columns(snapshot_id), - captured_by="setup-actor", - ) + await seed_guide_snapshot_rows( + session, project_id=project_id, guide_id=guide_id, + version=guide_version, snapshot_id=snapshot_id, ) + snapshot = await session.get(GuideSourceSnapshot, snapshot_id) + snapshot.captured_by = "setup-actor" await session.flush() session.add( SubmissionArtifactPolicy( @@ -568,6 +549,8 @@ async def _seed_checker_output_relationships(session) -> tuple[str, str, str]: guide.selected_revision_policy_generation = 1 guide.selected_revision_policy_hash = revision_hash guide.status = "active" + guide.approved_by = guide.created_by = "setup-actor" + guide.effective_at = now await session.flush() session.add( WorkstreamTask( @@ -2301,7 +2284,7 @@ async def test_guide_admission_derives_three_scopes_without_provider_evidence( media_type="application/pdf", ) as wrong_source: with pytest.raises( - ArtifactAdmissionRelationshipError, + ArtifactAdmissionConflictError, match="guide source ingest conflicts with prepared bytes", ): wrong_prepared = _AllowGuidePreparedAuthorization(context.actor_profile_id) @@ -2326,15 +2309,9 @@ async def test_guide_admission_derives_three_scopes_without_provider_evidence( GuideSourceArtifactIngest.source_item_id == item_id ) ) - scopes = ( - ( - await session.execute( - select(ArtifactAdmissionScope).order_by(ArtifactAdmissionScope.scope_type) - ) - ) - .scalars() - .all() - ) + scopes = (await session.scalars( + select(ArtifactAdmissionScope).order_by(ArtifactAdmissionScope.scope_type) + )).all() assert attempt is not None assert staged is not None assert staged.sha256 == expected_sha256 @@ -2353,9 +2330,8 @@ async def test_guide_admission_derives_three_scopes_without_provider_evidence( } assert len(result.charge_ids) == 3 assert await _count(session, ArtifactPutAttempt) == 1 - assert await _count(session, ArtifactContent) == 0 - assert await _count(session, ArtifactReplica) == 0 - assert await _count(session, ArtifactOperationReceipt) == 0 + await _assert_no_admission_rows( + session, ArtifactContent, ArtifactReplica, ArtifactOperationReceipt) with pytest.raises(DBAPIError): await session.execute( text( diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index d975fcd5a..c15f8727c 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -160,7 +160,6 @@ def test_legacy_compatibility_dependency_has_fixed_consumer_allowlist() -> None: assert {path for path, source in sources.items() if "get_registered_actor" in source} == { "api/deps/auth.py", - "api/routes/auth.py", "modules/checkers/router.py", "modules/projects/router.py", "modules/tasks/router.py", @@ -201,7 +200,7 @@ def test_legacy_compatibility_dependency_has_fixed_consumer_allowlist() -> None: } -async def test_valid_dev_token_resolves_actor_context( +async def test_valid_dev_token_resolves_canonical_profile( monkeypatch: pytest.MonkeyPatch, auth_database_env: str, ) -> None: @@ -221,25 +220,20 @@ async def test_valid_dev_token_resolves_actor_context( base_url="http://testserver", ) as client: response = await client.get( - "/api/v1/auth/me", + "/api/v1/actors/me", headers={"Authorization": "Bearer local-token"}, ) assert response.status_code == 200 body = response.json() - assert body["actor_id"] - assert body["external_subject"] == "flow-subject-1" - assert body["external_issuer"] == "flow-dev-issuer" - assert body["email"] is None + assert body["actor_profile_id"] + assert body["actor_kind"] == "human" + assert body["status"] == "active" + assert body["domains"] == ["contributor"] + assert body["admin_roles"] == [] + assert body["contact_email"] is None assert body["display_name"] is None - assert body["roles"] == ["contributor", "reviewer"] - assert body["auth_source"] == "dev_mock" - assert body["is_dev_auth"] is True - assert body["audit_context"]["actor_id"] == body["actor_id"] - assert body["audit_context"]["external_subject"] == "flow-subject-1" - assert body["audit_context"]["external_issuer"] == "flow-dev-issuer" - assert body["audit_context"]["auth_source"] == "dev_mock" - assert body["audit_context"]["is_dev_auth"] is True + assert not {"roles", "external_subject", "external_issuer", "audit_context"} & body.keys() async def test_signed_flow_token_authorizes_actor_self_read_and_update( @@ -712,7 +706,6 @@ async def unavailable_replay(self, **kwargs): for path, expected_code in ( ("/api/v1/actors/me", "permission_not_granted"), - ("/api/v1/auth/me", "service_actor_not_provisioned"), ): service_denial = await client.get(path, headers=service_headers) assert service_denial.status_code == 403 @@ -1432,7 +1425,7 @@ async def barrier_lock_control(self): await db_session.dispose_engine() -async def test_auth_me_maps_actor_registry_failure_to_service_unavailable( +async def test_actor_self_maps_actor_registry_failure_to_service_unavailable( monkeypatch: pytest.MonkeyPatch, auth_database_env: str, ) -> None: @@ -1455,7 +1448,7 @@ async def fail_resolve_actor(self, token, *, request_id, correlation_id): base_url="http://testserver", ) as client: response = await client.get( - "/api/v1/auth/me", + "/api/v1/actors/me", headers={"Authorization": "Bearer local-token"}, ) @@ -3858,7 +3851,8 @@ async def test_no_local_login_password_or_session_routes() -> None: "sessions", } - assert "/api/v1/auth/me" in paths + assert "/api/v1/actors/me" in paths + assert "/api/v1/auth/me" not in paths assert "/api/v1/demo/worker-profile" not in paths assert not any( segment in forbidden_segments for path in paths for segment in path.strip("/").split("/") diff --git a/backend/tests/test_behavior_ownership.py b/backend/tests/test_behavior_ownership.py index 5945ca212..803306188 100644 --- a/backend/tests/test_behavior_ownership.py +++ b/backend/tests/test_behavior_ownership.py @@ -1781,3 +1781,19 @@ def test_partition_accepts_only_exact_external_api_drill_target() -> None: _partition(sorted({retained, *expected, "backend/scripts/extra_api_drill.py"})), trusted, ) + + +def test_partition_accepts_only_exact_guide_document_lookup_target() -> None: + expected = {"backend/app/modules/projects/document_upload.py"} + assert ownership.POL_04B2_PARTITION_TARGETS == expected + assert ownership.group_for_target(next(iter(expected))) == "lifecycle" + retained = "backend/app/core/config.py" + trusted = _partition([retained]) + ownership._validate_additive_partition_transition( + _partition(sorted({retained, *expected})), trusted, + ) + with pytest.raises(ownership.BehaviorOwnershipError, match="untrusted_partition_change"): + ownership._validate_additive_partition_transition( + _partition(sorted({retained, *expected, "backend/app/modules/projects/unregistered_upload.py"})), + trusted, + ) diff --git a/backend/tests/test_checkers.py b/backend/tests/test_checkers.py index ee902274c..16fe82445 100644 --- a/backend/tests/test_checkers.py +++ b/backend/tests/test_checkers.py @@ -119,7 +119,7 @@ async def checker_client(checker_database_env: str) -> AsyncIterator[AsyncClient transport=ASGITransport(app=app), base_url="http://testserver", ) as client: - admission = await client.get("/api/v1/auth/me", headers=auth_headers()) + admission = await client.get("/api/v1/actors/me", headers=auth_headers()) assert admission.status_code == 200, admission.text async with db_session.get_session_factory()() as session: await grant_system_project_manager( diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index 8bbe1b511..66faa43a9 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -55,6 +55,7 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: == modules_by_lane["project_lifecycle_b"] == { "tests/projects/test_active_guide_repository.py", + "tests/test_guide_document_intake.py", "tests/projects/guide_compilation/test_capability_growth.py", "tests/projects/guide_compilation/test_capability_growth_postgresql.py", "tests/projects/guide_compilation/test_compilation_storage_limit.py", diff --git a/backend/tests/test_default_pre_submit_execution.py b/backend/tests/test_default_pre_submit_execution.py index a24233809..f6ebc48f4 100644 --- a/backend/tests/test_default_pre_submit_execution.py +++ b/backend/tests/test_default_pre_submit_execution.py @@ -2,7 +2,7 @@ from __future__ import annotations -from project_create_fixtures import guide_snapshot_columns, seed_guide_snapshot_rows +from project_create_fixtures import GUIDE_CREATION_CUSTODY_TRIGGERS, guide_snapshot_columns, seed_guide_snapshot_rows from app.modules.projects.models import ReviewPolicy @@ -443,10 +443,8 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( engine = create_async_engine(isolated_database_env) custody_triggers = ( ("projects", "project_creation_custody"), - ("project_guides", "guide_mutation_product_custody"), - ("project_guides", "guide_task_examples_create_custody"), + *GUIDE_CREATION_CUSTODY_TRIGGERS, ("project_guides", "guide_lineage_lifecycle_guard"), - ("guide_source_snapshots", "source_snapshot_product_custody"), ("submission_artifact_policies", "submission_policy_creation_custody"), ( "effective_project_submission_artifact_policies", diff --git a/backend/tests/test_guide_artifacts.py b/backend/tests/test_guide_artifacts.py index 3bb26e393..8135bd582 100644 --- a/backend/tests/test_guide_artifacts.py +++ b/backend/tests/test_guide_artifacts.py @@ -17,6 +17,7 @@ import app.adapters.artifacts as artifact_adapters from app.adapters.artifacts import get_guide_artifact_ingest_command +from app.modules.projects.router import guide_document_upload_command from app.core.config import Settings from app.interfaces.artifact_operations import GuideArtifactIngestRequest from app.modules.artifacts.preparation import ( @@ -32,9 +33,8 @@ GuideArtifactAdmissionRequest, GuideArtifactIngestAuthorityFacts, ) -from app.modules.artifacts.authorization import DenyGuideArtifactPreparedAuthorization from app.modules.artifacts.authorization import PreparedGuideArtifactAuthorization -from app.modules.artifacts.authorization import get_artifact_authorization_context +from app.modules.projects.guide_mutation_router import guide_authorization_actor from app.modules.artifacts.authorization import get_guide_artifact_prepared_authorization from app.modules.artifacts.authorization import guide_ingest_prepared_request_digest from app.modules.artifacts.service import ( @@ -54,7 +54,8 @@ PreparedAuthorizationInput, PreparedAuthorizationHandleInvalid, ) -from app.modules.projects.router import ingest_guide_source_artifact +from app.modules.projects.api.guide_documents import GuideDocumentUploadTarget +from app.modules.projects.router import upload_guide_document from app.modules.projects.router import router as projects_router @@ -135,6 +136,23 @@ def close(self) -> None: self.closed = True +class _RejectPreparedAuthority(_AllowPreparedAuthority): + async def prepare(self, **values): + raise ArtifactAuthorityDeniedError("guide artifact ingest is unavailable") + + +class _UploadTargets: + async def resolve(self, project_id, guide_id, document_id, *, for_update): + return GuideDocumentUploadTarget(SNAPSHOT_ID, SNAPSHOT_ID, 1, "application/pdf", 0) + + +def _command(service, authority): + return PreparedGuideArtifactIngestCommand( + service, authority, _UploadTargets(), maximum_document_bytes=64, + maximum_total_bytes=128, + ) + + class _FailCommitAuthority(_AllowPreparedAuthority): @asynccontextmanager async def transaction(self): @@ -314,8 +332,8 @@ async def source() -> AsyncIterator[bytes]: read = True yield b"must not be read" - authority = DenyGuideArtifactPreparedAuthorization() - command = PreparedGuideArtifactIngestCommand( + authority = _RejectPreparedAuthority() + command = _command( _service( preparation, _Admission(), @@ -326,15 +344,7 @@ async def source() -> AsyncIterator[bytes]: ) try: with pytest.raises(ArtifactAuthorityDeniedError): - await command.ingest( - authorization_context=_context(), - project_id=PROJECT_ID, - guide_id=GUIDE_ID, - guide_source_snapshot_id=SNAPSHOT_ID, - source_item_id=ITEM_ID, - idempotency_key=uuid4(), - byte_source=source(), - ) + await command.ingest(authorization_context=_context(), project_id=PROJECT_ID, guide_id=GUIDE_ID, source_item_id=ITEM_ID, idempotency_key=uuid4(), byte_source=source(), content_type='application/pdf', content_length=None) assert not read assert preparation.pending_cleanup_count == 0 finally: @@ -541,16 +551,8 @@ async def test_guide_ingest_uses_server_commitment_and_existing_put_path( authority, ) try: - command = PreparedGuideArtifactIngestCommand(service, authority) - result = await command.ingest( - authorization_context=authority.context, - project_id=PROJECT_ID, - guide_id=GUIDE_ID, - guide_source_snapshot_id=SNAPSHOT_ID, - source_item_id=ITEM_ID, - idempotency_key=uuid4(), - byte_source=_bytes(DOCUMENT_BYTES[:7], DOCUMENT_BYTES[7:]), - ) + command = _command(service, authority) + result = await command.ingest(authorization_context=authority.context, project_id=PROJECT_ID, guide_id=GUIDE_ID, source_item_id=ITEM_ID, idempotency_key=uuid4(), byte_source=_bytes(DOCUMENT_BYTES[:7], DOCUMENT_BYTES[7:]), content_type='application/pdf', content_length=None) assert result.sha256 == authority.admissions[0].sha256 assert result.byte_count == len(DOCUMENT_BYTES) assert orchestrator.continuations == [ATTEMPT_ID] @@ -608,21 +610,13 @@ async def test_guide_ingest_cleans_prepared_bytes_when_prep_commit_fails( preparation, manager = _preparation(tmp_path) authority = _FailCommitAuthority() orchestrator = _Orchestrator(authority=authority) - command = PreparedGuideArtifactIngestCommand( + command = _command( _service(preparation, _Admission(authority=authority), orchestrator, authority), authority, ) try: with pytest.raises(RuntimeError, match="PREP commit failed"): - await command.ingest( - authorization_context=authority.context, - project_id=PROJECT_ID, - guide_id=GUIDE_ID, - guide_source_snapshot_id=SNAPSHOT_ID, - source_item_id=ITEM_ID, - idempotency_key=uuid4(), - byte_source=_bytes(DOCUMENT_BYTES), - ) + await command.ingest(authorization_context=authority.context, project_id=PROJECT_ID, guide_id=GUIDE_ID, source_item_id=ITEM_ID, idempotency_key=uuid4(), byte_source=_bytes(DOCUMENT_BYTES), content_type='application/pdf', content_length=None) assert orchestrator.puts == 0 assert authority.closed assert preparation.pending_cleanup_count == 0 @@ -642,15 +636,7 @@ async def test_exact_replay_observes_without_second_provider_put(tmp_path: Path) authority, ) try: - result = await PreparedGuideArtifactIngestCommand(service, authority).ingest( - authorization_context=authority.context, - project_id=PROJECT_ID, - guide_id=GUIDE_ID, - guide_source_snapshot_id=SNAPSHOT_ID, - source_item_id=ITEM_ID, - idempotency_key=uuid4(), - byte_source=_bytes(DOCUMENT_BYTES), - ) + result = await _command(service, authority).ingest(authorization_context=authority.context, project_id=PROJECT_ID, guide_id=GUIDE_ID, source_item_id=ITEM_ID, idempotency_key=uuid4(), byte_source=_bytes(DOCUMENT_BYTES), content_type='application/pdf', content_length=None) assert result.replayed assert result.status == "stale" assert orchestrator.resolutions == 1 @@ -757,51 +743,36 @@ async def test_canonical_lineage_drift_stops_before_provider_io(tmp_path: Path) ) try: with pytest.raises(Exception, match="canonical lineage"): - await PreparedGuideArtifactIngestCommand(service, authority).ingest( - authorization_context=authority.context, - project_id=PROJECT_ID, - guide_id=GUIDE_ID, - guide_source_snapshot_id=SNAPSHOT_ID, - source_item_id=ITEM_ID, - idempotency_key=uuid4(), - byte_source=_bytes(DOCUMENT_BYTES), - ) + await _command(service, authority).ingest(authorization_context=authority.context, project_id=PROJECT_ID, guide_id=GUIDE_ID, source_item_id=ITEM_ID, idempotency_key=uuid4(), byte_source=_bytes(DOCUMENT_BYTES), content_type='application/pdf', content_length=None) assert orchestrator.puts == 0 assert preparation.pending_cleanup_count == 0 finally: manager.close() -def test_hidden_guide_ingest_route_is_not_in_openapi() -> None: +def test_guide_document_upload_route_is_in_openapi() -> None: route = next( route for route in projects_router.routes - if getattr(route, "name", None) == "ingest_guide_source_artifact" + if getattr(route, "name", None) == "upload_guide_document" ) - assert route.include_in_schema is False + assert route.include_in_schema is True @pytest.mark.asyncio -async def test_production_composition_denies_before_disabled_runtime_is_opened() -> None: +async def test_production_composition_denies_before_runtime_is_opened(monkeypatch) -> None: request = Request({"type": "http", "method": "POST", "path": "/hidden", "headers": []}) request.scope["app"] = type("App", (), {"state": type("State", (), {})()})() request.app.state.settings = Settings() command = get_guide_artifact_ingest_command( request, object(), # type: ignore[arg-type] - DenyGuideArtifactPreparedAuthorization(), + _RejectPreparedAuthority(), DenyArtifactInternalAuthority(), + _UploadTargets(), ) with pytest.raises(ArtifactAuthorityDeniedError): - await command.ingest( - authorization_context=_context(), - project_id=PROJECT_ID, - guide_id=GUIDE_ID, - guide_source_snapshot_id=SNAPSHOT_ID, - source_item_id=ITEM_ID, - idempotency_key=uuid4(), - byte_source=_bytes(b"never read"), - ) + await command.ingest(authorization_context=_context(), project_id=PROJECT_ID, guide_id=GUIDE_ID, source_item_id=ITEM_ID, idempotency_key=uuid4(), byte_source=_bytes(b'never read'), content_type='application/pdf', content_length=None) @pytest.mark.asyncio @@ -838,23 +809,16 @@ def fail_scratch(_settings: Settings): object(), # type: ignore[arg-type] authority, DenyArtifactInternalAuthority(), + _UploadTargets(), ) with pytest.raises(RuntimeError, match="scratch construction failed"): - await command.ingest( - authorization_context=authority.context, - project_id=PROJECT_ID, - guide_id=GUIDE_ID, - guide_source_snapshot_id=SNAPSHOT_ID, - source_item_id=ITEM_ID, - idempotency_key=uuid4(), - byte_source=_bytes(b"never read"), - ) + await command.ingest(authorization_context=authority.context, project_id=PROJECT_ID, guide_id=GUIDE_ID, source_item_id=ITEM_ID, idempotency_key=uuid4(), byte_source=_bytes(b'never read'), content_type='application/pdf', content_length=None) assert closed == 1 assert authority.closed @pytest.mark.asyncio -async def test_hidden_http_route_conceals_fail_closed_authority() -> None: +async def test_document_http_route_conceals_fail_closed_authority() -> None: body_read = False async def receive() -> dict[str, object]: @@ -872,55 +836,56 @@ async def receive() -> dict[str, object]: receive, ) with pytest.raises(HTTPException) as denied: - await ingest_guide_source_artifact( - project_id=str(PROJECT_ID), - guide_id=str(GUIDE_ID), - source_snapshot_id=str(SNAPSHOT_ID), - source_item_id=str(ITEM_ID), + await upload_guide_document( + project_id=PROJECT_ID, + guide_id=GUIDE_ID, + document_id=ITEM_ID, request=request, - context=_context(), + authorization=(uuid4(), _context()), ingest=_UnavailableCommand(), # type: ignore[arg-type] - idempotency_key=str(uuid4()), + content_type="application/pdf", + content_length=None, ) assert denied.value.status_code == 404 assert not body_read @pytest.mark.asyncio -async def test_hidden_http_route_does_not_conceal_unexpected_value_error() -> None: +async def test_document_http_route_does_not_conceal_unexpected_value_error() -> None: request = Request({"type": "http", "method": "POST", "path": "/hidden", "headers": []}) with pytest.raises(ValueError, match="unexpected implementation failure"): - await ingest_guide_source_artifact( - project_id=str(PROJECT_ID), - guide_id=str(GUIDE_ID), - source_snapshot_id=str(SNAPSHOT_ID), - source_item_id=str(ITEM_ID), + await upload_guide_document( + project_id=PROJECT_ID, + guide_id=GUIDE_ID, + document_id=ITEM_ID, request=request, - context=_context(), + authorization=(uuid4(), _context()), ingest=_UnexpectedValueErrorCommand(), # type: ignore[arg-type] - idempotency_key=str(uuid4()), + content_type="application/pdf", + content_length=None, ) @pytest.mark.asyncio @pytest.mark.parametrize("idempotency_key", [None, "not-a-uuid"]) -async def test_hidden_http_route_conceals_invalid_idempotency_key( +async def test_document_http_route_conceals_invalid_idempotency_key( idempotency_key: str | None, ) -> None: app = FastAPI() app.include_router(projects_router) - app.dependency_overrides[get_artifact_authorization_context] = _context + # This route-shape test isolates identity resolution; PostgreSQL covers the + # real key-gated actor dependency and zero provisioning on invalid keys. + app.dependency_overrides[guide_authorization_actor] = lambda: None command = _MustNotCallCommand() - app.dependency_overrides[get_guide_artifact_ingest_command] = lambda: command + app.dependency_overrides[guide_document_upload_command] = lambda: command headers = {} if idempotency_key is None else {"Idempotency-Key": idempotency_key} path = ( - f"/projects/{PROJECT_ID}/guides/{GUIDE_ID}/source-snapshots/" - f"{SNAPSHOT_ID}/items/{ITEM_ID}/artifact" + f"/projects/{PROJECT_ID}/guides/{GUIDE_ID}/documents/{ITEM_ID}/content" ) async with AsyncClient( transport=ASGITransport(app=app), base_url="http://testserver", ) as client: response = await client.post(path, headers=headers, content=b"never read") - assert response.status_code == 404 + assert response.status_code == 422 assert not command.called diff --git a/backend/tests/test_guide_document_api_contract.py b/backend/tests/test_guide_document_api_contract.py index ebb8a2255..71079fe4c 100644 --- a/backend/tests/test_guide_document_api_contract.py +++ b/backend/tests/test_guide_document_api_contract.py @@ -4,12 +4,12 @@ from pydantic import ValidationError from app.modules.projects.schemas import ( - ProjectGuideCreate, ProjectGuideUpdate, ProjectGuideResponse, GuideSourceSnapshotItemInput, + ProjectGuideCreate, ProjectGuideUpdate, ProjectGuideResponse, ProjectGuideDocumentInput, ) @pytest.mark.parametrize("schema,payload", [ - (ProjectGuideCreate, {"version": "v0.1", "task_examples": [{"content": "Review a claim."}]}), + (ProjectGuideCreate, {"version": "v0.1", "documents": [{"label": "guide.pdf", "media_type": "application/pdf"}], "task_examples": [{"content": "Review a claim."}]}), (ProjectGuideUpdate, {"change_summary": "Corrected source documents"}), ]) @pytest.mark.parametrize("field", ["content_markdown", "retained_content_markdown", "content", "inline_text"]) @@ -32,9 +32,8 @@ def test_current_guide_response_excludes_retained_body(): ]) def test_source_metadata_rejects_superseded_or_unsupported_ingress(patch): with pytest.raises(ValidationError): - GuideSourceSnapshotItemInput.model_validate({ - "source_kind": "document", "source_label": "guide.pdf", - "ingestion_adapter": "upload", "media_type": "application/pdf", **patch, + ProjectGuideDocumentInput.model_validate({ + "label": "guide.pdf", "media_type": "application/pdf", **patch, }) @@ -43,6 +42,8 @@ def test_source_metadata_rejects_superseded_or_unsupported_ingress(patch): ("POST", "post-submit-checker-policy/approve"), ("POST", "post-submit-checker-policy/request-correction"), ("POST", "source-snapshots/{snapshot_id}/run-sufficiency-agent"), + ("POST", "source-snapshots"), + ("POST", "source-snapshots/{source_snapshot_id}/items/{source_item_id}/artifact"), ]) def test_superseded_setup_endpoints_are_not_registered(method, suffix): from app.core.config import Settings @@ -104,7 +105,7 @@ def test_superseded_activation_implementation_is_absent(): @pytest.mark.parametrize("examples", [None, [], [{"content": ""}], [{"content": " \t\n\u2003"}], [{}]]) def test_guide_create_requires_at_least_one_nonblank_task_example(examples): - payload = {"version": "v0.1"} + payload = {"version": "v0.1", "documents": [{"label": "guide.pdf", "media_type": "application/pdf"}]} if examples is not None: payload["task_examples"] = examples with pytest.raises(ValidationError, match="task_examples"): @@ -116,7 +117,7 @@ def test_guide_examples_accept_minimal_and_diverse_descriptions_without_task_sch {"content": "Draft a claim review."}, {"content": " Reproduce a reported result.\nKeep this whitespace. 雪", "title": "Paper task", "labels": ["research"]}, ] - created = ProjectGuideCreate.model_validate({"version": "v0.1", "task_examples": examples}) + created = ProjectGuideCreate.model_validate({"version": "v0.1", "documents": [{"label": "guide.pdf", "media_type": "application/pdf"}], "task_examples": examples}) assert [item.content for item in created.task_examples] == [item["content"] for item in examples] assert created.task_examples[0].title is None assert created.task_examples[0].labels == () @@ -137,7 +138,7 @@ def test_task_example_commitment_includes_order_content_title_and_labels(): @pytest.mark.parametrize("content", ["雪" * 50_000, "\n" * 65_535 + "x"], ids=("multibyte_utf8", "json_escaping")) def test_task_example_aggregate_budget_counts_utf8_and_json_escaping(content): with pytest.raises(ValidationError, match="aggregate byte limit"): - ProjectGuideCreate.model_validate({"version": "v0.1", "task_examples": [{"content": content}]}) + ProjectGuideCreate.model_validate({"version": "v0.1", "documents": [{"label": "guide.pdf", "media_type": "application/pdf"}], "task_examples": [{"content": content}]}) def test_guide_create_authorization_projection_contains_commitments_not_example_text(): @@ -149,7 +150,7 @@ def test_guide_create_authorization_projection_contains_commitments_not_example_ from app.modules.projects.guide_mutation_service import GuideMutationService sentinel = "private-example-text-sentinel" - payload = ProjectGuideCreate.model_validate({"version": "v0.1", "task_examples": [ + payload = ProjectGuideCreate.model_validate({"version": "v0.1", "documents": [{"label": "guide.pdf", "media_type": "application/pdf"}], "task_examples": [ {"content": sentinel, "title": "private-example-title", "labels": ["private-label"]}, ]}) caller, digest = GuideMutationService._input( @@ -163,3 +164,27 @@ def test_guide_create_authorization_projection_contains_commitments_not_example_ assert caller.request_value["request_digest"] == digest assert caller.request_value["task_examples_count"] == 1 assert caller.request_value["task_examples_hash"].startswith("sha256:") + + +def test_document_upload_openapi_binary_body_and_bounded_responses(): + from app.core.config import Settings + from app.main import create_app + document = create_app(Settings(environment="test")).openapi() + operation = document["paths"]["/api/v1/projects/{project_id}/guides/{guide_id}/documents/{document_id}/content"]["post"] + body = operation["requestBody"] + assert body["required"] is True + assert set(body["content"]) == { + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + } + assert all(value["schema"] == {"type": "string", "format": "binary"} for value in body["content"].values()) + responses = operation["responses"] + assert responses["202"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/GuideArtifactIngestResponse"} + schema = document["components"]["schemas"]["GuideArtifactIngestResponse"] + assert set(schema["properties"]) == {"document_id", "sha256", "byte_count", "status", "replayed"} + for code in ("404", "409", "413", "422", "503"): + assert responses[code]["description"] + assert responses[code]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/ApiErrorResponse"} diff --git a/backend/tests/test_guide_document_intake.py b/backend/tests/test_guide_document_intake.py new file mode 100644 index 000000000..e5cc86c50 --- /dev/null +++ b/backend/tests/test_guide_document_intake.py @@ -0,0 +1,851 @@ +"""One public guide create owns its complete immutable document declaration.""" + +from uuid import uuid4 +from types import SimpleNamespace +import os +import hashlib +import pytest + +from sqlalchemy import select + +from app.db import session as db_session +from app.modules.projects.models import ( + GuideMutationIdempotencyRecord, + GuideSourceSnapshot, + ProjectSetupRun, +) +from projects.client_fixtures import ( + auth_headers, + project_client as project_client, + project_database_env as _project_database_env, +) +from projects.guide_fixtures import create_project + +base_project_database_env = _project_database_env + + +@pytest.fixture +def project_database_env(base_project_database_env, monkeypatch, tmp_path): + from app.core.config import get_settings + + values = { + "ARTIFACT_STORE_BACKEND": "s3_compatible", + "ARTIFACT_S3_PROVIDER_PROFILE": "minio", + "ARTIFACT_S3_REGION": "us-east-1", + "ARTIFACT_S3_BUCKET": os.environ["WORKSTREAM_TEST_MINIO_BUCKET"], + "ARTIFACT_S3_ENDPOINT_URL": os.environ["WORKSTREAM_TEST_MINIO_ENDPOINT"], + "ARTIFACT_S3_PRIVATE_PREFIX": f"{os.environ['WORKSTREAM_TEST_MINIO_PREFIX']}/guide-intake/{uuid4().hex}", + "ARTIFACT_S3_ADDRESSING_STYLE": "path", + "ARTIFACT_S3_CREDENTIAL_MODE": "local_static", + "ARTIFACT_S3_ACCESS_KEY_ID": "workstream-minio", + "ARTIFACT_S3_SECRET_ACCESS_KEY": "workstream-minio-secret-key", + "ARTIFACT_SCRATCH_ROOT": str(tmp_path / "scratch"), + "CELERY_TASK_ALWAYS_EAGER": "false", + } + for scope in ("TASK", "PRODUCER", "PROJECT", "DEPLOYMENT"): + values[f"ARTIFACT_ADMISSION_{scope}_MAXIMUM_BYTES"] = str(1024 * 1024) + for name, value in values.items(): + monkeypatch.setenv("WORKSTREAM_" + name, value) + get_settings.cache_clear() + yield base_project_database_env + get_settings.cache_clear() + + +async def test_create_declares_document_set_and_replays_exact_ids(project_client): + project = await create_project(project_client) + path = f"/api/v1/projects/{project['id']}/guides" + payload = { + "version": "initial", + "task_examples": [{"content": "Review a claim using the guide."}], + "documents": [ + {"label": "z-guide.pdf", "media_type": "application/pdf"}, + {"label": "a-appendix.pdf", "media_type": "application/pdf"}, + ], + } + headers = auth_headers() | {"Idempotency-Key": str(uuid4())} + created = await project_client.post(path, headers=headers, json=payload) + assert created.status_code == 201, created.text + body = created.json() + assert [item["label"] for item in body["documents"]] == ["z-guide.pdf", "a-appendix.pdf"] + assert [item["order"] for item in body["documents"]] == [0, 1] + assert body["setup"]["status"] == "awaiting_documents" + assert "source_snapshot_id" not in body + replay = await project_client.post(path, headers=headers, json=payload) + assert replay.status_code == 201, replay.text + assert replay.json() == body + conflict = await project_client.post( + path, headers=headers, json=payload | {"documents": payload["documents"][::-1]} + ) + assert conflict.status_code == 409, conflict.text + async with db_session.get_session_factory()() as session: + snapshots = ( + await session.scalars( + select(GuideSourceSnapshot).where(GuideSourceSnapshot.guide_id == body["id"]) + ) + ).all() + setups = ( + await session.scalars( + select(ProjectSetupRun).where(ProjectSetupRun.guide_id == body["id"]) + ) + ).all() + mutations = ( + await session.scalars( + select(GuideMutationIdempotencyRecord).where( + GuideMutationIdempotencyRecord.project_id == project["id"] + ) + ) + ).all() + assert len(snapshots) == len(setups) == 1 + assert {row.action_id for row in mutations} == { + "project.guide.create", + "project.guide_source_snapshot.create", + } + assert len(mutations) == 2 + assert all(row.status == "committed" for row in mutations) + assert len({row.idempotency_key for row in mutations}) == 1 + assert setups[0].source_snapshot_id == snapshots[0].id + + +@pytest.mark.parametrize( + "invalid", ["content_type", "project", "guide", "document", "key", "declared_size", "setup_generation"] +) +async def test_upload_rejects_invalid_request_before_reading_body(project_client, invalid, monkeypatch): + project = await create_project(project_client) + created = await project_client.post( + f"/api/v1/projects/{project['id']}/guides", + headers=auth_headers(), + json={ + "version": "initial", + "task_examples": [{"content": "Review a claim."}], + "documents": [{"label": "guide.pdf", "media_type": "application/pdf"}], + }, + ) + assert created.status_code == 201, created.text + guide = created.json() + read = False + + async def body(): + nonlocal read + read = True + raise AssertionError("invalid upload must not read the request body") + yield b"" + + selectors = { + "project": project["id"], + "guide": guide["id"], + "document": guide["documents"][0]["document_id"], + } + headers = auth_headers() | {"Content-Type": "application/pdf"} + if invalid in selectors: + selectors[invalid] = str(uuid4()) + elif invalid == "content_type": + headers["Content-Type"] = "text/plain" + elif invalid == "key": + headers["Idempotency-Key"] = "invalid-key" + elif invalid == "setup_generation": + from dataclasses import replace + from app.modules.projects.document_upload import ProjectGuideDocumentUploadTargets + original_resolve = ProjectGuideDocumentUploadTargets.resolve + async def drift(self, *args, for_update): + target = await original_resolve(self, *args, for_update=for_update) + return replace(target, setup_generation=target.setup_generation + 1) if for_update else target + monkeypatch.setattr(ProjectGuideDocumentUploadTargets, "resolve", drift) + else: + headers["Content-Length"] = str(1024 * 1024 * 1024) + response = await project_client.post( + f"/api/v1/projects/{selectors['project']}/guides/{selectors['guide']}/documents/{selectors['document']}/content", + headers=headers, + content=body(), + ) + assert response.status_code == {"key": 422, "content_type": 422, "declared_size": 413}.get(invalid, 404), ( + response.text + ) + assert read is False + await _assert_no_upload_effects() + + +@pytest.mark.parametrize("recover_callback", [False, True]) +async def test_all_documents_stored_dispatches_once_through_minio( + project_client, monkeypatch, recover_callback +): + from app.core.config import get_settings + from app.modules.actors.service_identities import ServiceIdentity + from app.modules.artifacts.models import ArtifactReplica, ArtifactPutAttempt + from app.workers.project_setup import run_project_guide_compilation + from app.adapters.artifacts import internal_workers + + deliveries = [] + + def publish(*, args, task_id): + deliveries.append((args, task_id)) + return SimpleNamespace(id=task_id) + + monkeypatch.setattr(run_project_guide_compilation, "apply_async", publish) + provision = await project_client.post( + "/api/v1/service-actors", + headers=auth_headers(), + json={ + "service_identity": ServiceIdentity.ARTIFACT_PUT_RESOLVER.value, + "subject": "guide-intake-test-put-resolver", + "reason": "Isolated guide upload proof.", + }, + ) + assert provision.status_code == 201, provision.text + project = await create_project(project_client) + created = await project_client.post( + f"/api/v1/projects/{project['id']}/guides", + headers=auth_headers(), + json={ + "version": "initial", + "task_examples": [{"content": "Review a claim."}], + "documents": [ + {"label": name, "media_type": "application/pdf"} + for name in ("guide.pdf", "appendix.pdf") + ], + }, + ) + assert created.status_code == 201, created.text + guide = created.json() + originals = [b"%PDF-1.7\nGuide fixture\n%%EOF", b"%PDF-1.7\nAppendix fixture\n%%EOF"] + for index, (document, original) in enumerate(zip(guide["documents"], originals, strict=True)): + path = f"/api/v1/projects/{project['id']}/guides/{guide['id']}/documents/{document['document_id']}/content" + headers = auth_headers() | {"Content-Type": ("Application/PDF", "application/pdf; name=appendix.pdf")[index]} + real_callback = internal_workers.continue_guide_setup_after_stored_document + if index == 1 and recover_callback: + + async def unavailable_callback(_attempt_id): + raise RuntimeError("injected callback failure") + + monkeypatch.setattr( + internal_workers, "continue_guide_setup_after_stored_document", unavailable_callback + ) + response = await project_client.post(path, headers=headers, content=original) + if index == 1 and recover_callback: + assert deliveries == [] + monkeypatch.setattr( + internal_workers, "continue_guide_setup_after_stored_document", real_callback + ) + + async def recover(attempt_id): + from uuid import UUID + + await real_callback(UUID(attempt_id)) + + assert await internal_workers.scan_guide_setup_continuations(recover) == 1 + assert await internal_workers.scan_guide_setup_continuations(recover) == 0 + assert response.status_code == 202, response.text + assert response.json()["sha256"] == "sha256:" + hashlib.sha256(original).hexdigest() + assert set(response.json()) == {"document_id", "sha256", "byte_count", "status", "replayed"} + assert len(deliveries) == index + replay = await project_client.post(path, headers=headers, content=original) + assert replay.status_code == 202, replay.text + assert replay.json()["replayed"] is True + assert len(deliveries) == index + before_keys = await _stored_keys(get_settings()) + changed = await project_client.post(path, headers=headers, content=original + b"changed") + assert changed.status_code == 409, changed.text + another_key = await project_client.post( + path, headers=auth_headers() | {"Content-Type": "application/pdf"}, content=original, + ) + assert another_key.status_code == 409, another_key.text + assert await _stored_keys(get_settings()) == before_keys + assert len(deliveries) == index + async with db_session.get_session_factory()() as session: + run = await session.get(ProjectSetupRun, guide["setup"]["id"]) + assert run.status == ("awaiting_documents" if index == 0 else "queued") + async with db_session.get_session_factory()() as session: + attempts = ( + await session.scalars( + select(ArtifactPutAttempt).where(ArtifactPutAttempt.project_id == project["id"]) + ) + ).all() + replicas = (await session.scalars(select(ArtifactReplica))).all() + assert len(attempts) == len(replicas) == 2 + bootstrap, store = _open_store(get_settings()) + try: + stored = [ + b"".join([chunk async for chunk in store.open(row.provider_object_ref)]) + for row in replicas + ] + assert set(stored) == set(originals) + finally: + store.close() + bootstrap.close() + + +async def test_create_replay_requires_current_manager_authority(project_client): + from datetime import datetime, UTC + from app.modules.authorization.models import AdminRoleGrant + + project = await create_project(project_client) + path = f"/api/v1/projects/{project['id']}/guides" + payload = { + "version": "initial", + "task_examples": [{"content": "Review a claim."}], + "documents": [{"label": "guide.pdf", "media_type": "application/pdf"}], + } + headers = auth_headers() + first = await project_client.post(path, headers=headers, json=payload) + assert first.status_code == 201, first.text + async with db_session.get_session_factory()() as session: + grants = ( + await session.scalars( + select(AdminRoleGrant).where( + AdminRoleGrant.role == "project_manager", + AdminRoleGrant.status == "active", + ) + ) + ).all() + assert grants + for grant in grants: + grant.status = "revoked" + grant.version += 1 + grant.revoked_by_actor_profile_id = grant.target_actor_profile_id + grant.revoked_by_admin_role_grant_id = grant.granted_by_admin_role_grant_id + grant.revoked_reason = "Current-authority replay proof" + grant.revoked_at = datetime.now(UTC) + await session.commit() + replay = await project_client.post(path, headers=headers, json=payload) + assert replay.status_code == 403, replay.text + assert "permission_not_granted" in replay.text + async with db_session.get_session_factory()() as session: + rows = ( + await session.scalars( + select(GuideMutationIdempotencyRecord).where( + GuideMutationIdempotencyRecord.project_id == project["id"], + ) + ) + ).all() + assert len(rows) == 2 + assert all(row.status == "committed" for row in rows) + + +async def test_late_create_failure_rolls_back_both_authorities_and_all_product_rows( + project_client, monkeypatch +): + from sqlalchemy import func + from app.modules.projects.guide_mutation_repository import GuideMutationRepository + from app.modules.projects.models import ProjectGuide, GuideSourceSnapshotItem + from app.modules.tasks.models import AuditEvent + + project = await create_project(project_client) + async with db_session.get_session_factory()() as session: + before = await session.scalar(select(func.count()).select_from(AuditEvent)) + + staged = [] + original_complete = GuideMutationRepository.complete + + async def fail_complete(self, record, **values): + await original_complete(self, record, **values) + if record.action_id != "project.guide.create": + return + await self._session.flush() + for model, expected in ((ProjectGuide, 1), (GuideSourceSnapshot, 1), + (GuideSourceSnapshotItem, 1), (ProjectSetupRun, 1), + (GuideMutationIdempotencyRecord, 2)): + count = await self._session.scalar(select(func.count()).select_from(model)) + assert count == expected + staged.append(count) + assert await self._session.scalar(select(func.count()).select_from(AuditEvent)) > before + raise RuntimeError("injected complete-pair commit failure") + + monkeypatch.setattr(GuideMutationRepository, "complete", fail_complete) + response = await project_client.post( + f"/api/v1/projects/{project['id']}/guides", + headers=auth_headers(), + json={ + "version": "initial", + "task_examples": [{"content": "Review a claim."}], + "documents": [{"label": "guide.pdf", "media_type": "application/pdf"}], + }, + ) + assert response.status_code == 500, response.text + assert staged == [1, 1, 1, 1, 2] + async with db_session.get_session_factory()() as session: + for model in ( + ProjectGuide, + GuideSourceSnapshot, + GuideSourceSnapshotItem, + ProjectSetupRun, + GuideMutationIdempotencyRecord, + ): + assert await session.scalar(select(func.count()).select_from(model)) == 0 + assert await session.scalar(select(func.count()).select_from(AuditEvent)) == before + + +async def test_concurrent_create_replays_one_paired_operation(project_client): + import asyncio + + project = await create_project(project_client) + payload = { + "version": "initial", + "task_examples": [{"content": "Review a claim."}], + "documents": [{"label": "guide.pdf", "media_type": "application/pdf"}], + } + path = f"/api/v1/projects/{project['id']}/guides" + headers = auth_headers() + results = await asyncio.gather( + *(project_client.post(path, headers=headers, json=payload) for _ in range(2)) + ) + assert [result.status_code for result in results] == [201, 201], [ + result.text for result in results + ] + assert results[0].json() == results[1].json() + from sqlalchemy import func + from app.modules.projects.models import ProjectGuide, GuideSourceSnapshotItem + async with db_session.get_session_factory()() as session: + for model, expected in ((ProjectGuide, 1), (GuideSourceSnapshot, 1), + (GuideSourceSnapshotItem, 1), (ProjectSetupRun, 1), + (GuideMutationIdempotencyRecord, 2)): + assert await session.scalar(select(func.count()).select_from(model)) == expected + rows = (await session.scalars(select(GuideMutationIdempotencyRecord))).all() + assert {row.action_id for row in rows} == { + "project.guide.create", "project.guide_source_snapshot.create"} + assert {str(row.idempotency_key) for row in rows} == {headers["Idempotency-Key"]} + assert {row.status for row in rows} == {"committed"} + + +@pytest.mark.parametrize("guard_enabled", [True, False]) +async def test_database_rejects_individually_valid_but_cross_key_creation_pair( + project_client, monkeypatch, guard_enabled +): + """Both decisions and rows are valid; only their shared replay key is wrong.""" + from app.modules.projects.guide_mutation_repository import GuideMutationRepository + from sqlalchemy import func + from app.modules.projects.models import ProjectGuide + + project = await create_project(project_client) + original = GuideMutationRepository.reserve + + async def cross_key(self, **values): + if values["action_id"] == "project.guide_source_snapshot.create": + values["idempotency_key"] = uuid4() + return await original(self, **values) + + monkeypatch.setattr(GuideMutationRepository, "reserve", cross_key) + from sqlalchemy import text + from sqlalchemy.ext.asyncio import AsyncSession + from sqlalchemy.exc import IntegrityError + + errors = [] + original_commit = AsyncSession.commit + + async def observe_commit(self): + try: + return await original_commit(self) + except IntegrityError as error: + errors.append(str(error.orig)) + raise + + monkeypatch.setattr(AsyncSession, "commit", observe_commit) + guarded_tables = ( + "project_guides", + "guide_source_snapshots", + "guide_mutation_idempotency_records", + ) + try: + if not guard_enabled: + async with db_session.get_session_factory()() as session, session.begin(): + for table in guarded_tables: + await session.execute( + text(f"alter table {table} disable trigger require_document_creation_pair") + ) + response = await project_client.post( + f"/api/v1/projects/{project['id']}/guides", + headers=auth_headers(), + json={ + "version": "cross-key", + "task_examples": [{"content": "Review a claim."}], + "documents": [{"label": "guide.pdf", "media_type": "application/pdf"}], + }, + ) + if guard_enabled: + assert response.status_code == 503, response.text + assert len(errors) == 1 + assert "guide document creation pair is invalid" in errors[0] + else: + # Discriminating mutation: all unchanged authority/lineage guards pass. + assert response.status_code == 201, response.text + assert errors == [] + async with db_session.get_session_factory()() as session: + for model, count in ( + (ProjectGuide, 1), + (GuideSourceSnapshot, 1), + (ProjectSetupRun, 1), + (GuideMutationIdempotencyRecord, 2), + ): + assert await session.scalar(select(func.count()).select_from(model)) == ( + 0 if guard_enabled else count + ) + finally: + if not guard_enabled: + async with db_session.get_session_factory()() as session, session.begin(): + for table in guarded_tables: + await session.execute( + text(f"alter table {table} enable trigger require_document_creation_pair") + ) + + +def _open_store(settings): + from app.adapters.artifacts import create_artifact_store_bootstrap + from app.modules.artifacts.service import artifact_storage_namespace_spec + from app.interfaces.artifacts import ArtifactStoreNamespaceClaim + bootstrap = create_artifact_store_bootstrap(settings) + namespace = artifact_storage_namespace_spec(settings, bootstrap) + store = bootstrap.initialize_after_namespace_claim(ArtifactStoreNamespaceClaim( + adapter_identity=bootstrap.identity, + namespace_identity=bootstrap.namespace_identity, + namespace_fingerprint=namespace.namespace_fingerprint, + )) + return bootstrap, store + + +@pytest.mark.parametrize("missing", ["WORKSTREAM_TEST_MINIO_BUCKET", "WORKSTREAM_TEST_MINIO_PREFIX"]) +def test_intake_fixture_requires_runner_owned_storage(monkeypatch, tmp_path, missing): + monkeypatch.setenv("WORKSTREAM_TEST_MINIO_ENDPOINT", "http://127.0.0.1:9000") + monkeypatch.setenv("WORKSTREAM_TEST_MINIO_BUCKET", "workstream-ci-proof") + monkeypatch.setenv("WORKSTREAM_TEST_MINIO_PREFIX", "ci/isolated/proof") + monkeypatch.delenv(missing) + with pytest.raises(KeyError, match=missing): + next(project_database_env.__wrapped__("unused-database", monkeypatch, tmp_path)) + + +async def _stored_keys(settings): + from scripts.run_isolated_tests import _minio_client + async with _minio_client(settings.artifact_s3_endpoint_url) as client: + result = await client.list_objects_v2( + Bucket=settings.artifact_s3_bucket, + Prefix=settings.artifact_s3_private_prefix + "/", + ) + assert not result.get("IsTruncated", False) + return {item["Key"] for item in result.get("Contents", [])} + + +@pytest.mark.parametrize("original", [b"", b"not a PDF", b"PK\x03\x04wrong container"]) +async def test_invalid_bytes_are_correctable_and_leave_no_artifact(project_client, original): + from sqlalchemy import func + from app.core.config import get_settings + from app.adapters.artifacts import create_artifact_scratch_manager + from app.modules.artifacts.models import ArtifactPutAttempt, ArtifactReplica, ArtifactAdmissionCharge + project = await create_project(project_client) + created = await project_client.post( + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), + json={"version": "initial", "task_examples": [{"content": "Review a claim."}], + "documents": [{"label": "guide.pdf", "media_type": "application/pdf"}]}, + ) + assert created.status_code == 201, created.text + guide = created.json() + response = await project_client.post( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/documents/{guide['documents'][0]['document_id']}/content", + headers=auth_headers() | {"Content-Type": "application/pdf"}, content=original, + ) + assert response.status_code == 422, response.text + async with db_session.get_session_factory()() as session: + for model in (ArtifactPutAttempt, ArtifactReplica, ArtifactAdmissionCharge): + assert await session.scalar(select(func.count()).select_from(model)) == 0 + run = await session.get(ProjectSetupRun, guide["setup"]["id"]) + assert run.status == "awaiting_documents" + assert run.celery_task_id is None + settings = get_settings() + assert await _stored_keys(settings) == set() + manager = create_artifact_scratch_manager(settings) + try: + usage = await manager.usage() + assert usage.reservation_count == usage.reserved_bytes == 0 + finally: + manager.close() + + +@pytest.mark.parametrize("mode", ["distinct", "same_document", "aggregate_limit"]) +async def test_concurrent_last_documents_dispatch_one_setup(project_client, monkeypatch, mode): + import asyncio + from sqlalchemy import func + from app.core.config import get_settings + from app.modules.actors.service_identities import ServiceIdentity + from app.modules.artifacts.models import ArtifactPutAttempt, ArtifactReplica + from app.workers.project_setup import run_project_guide_compilation + deliveries = [] + def publish(*, args, task_id): + deliveries.append((args, task_id)) + return SimpleNamespace(id=task_id) + monkeypatch.setattr(run_project_guide_compilation, "apply_async", publish) + provision = await project_client.post("/api/v1/service-actors", headers=auth_headers(), json={ + "service_identity": ServiceIdentity.ARTIFACT_PUT_RESOLVER.value, + "subject": "guide-concurrent-put-resolver", "reason": "Isolated concurrent upload proof.", + }) + assert provision.status_code == 201, provision.text + project = await create_project(project_client) + created = await project_client.post( + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), + json={"version": "initial", "task_examples": [{"content": "Review a claim."}], + "documents": [{"label": label, "media_type": "application/pdf"} for label in ("guide.pdf", "appendix.pdf")]}, + ) + assert created.status_code == 201, created.text + guide = created.json() + if mode == "aggregate_limit": + app = project_client._transport.app + app.state.settings = app.state.settings.model_copy(update={"project_agent_max_total_document_bytes": 40}) + same_headers = auth_headers() | {"Content-Type": "application/pdf"} + async def upload(document): + return await project_client.post( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/documents/{document['document_id']}/content", + headers=same_headers if mode == "same_document" else auth_headers() | {"Content-Type": "application/pdf"}, + content=b"%PDF-1.7\n" + document["label"].encode() + b"\n%%EOF", + ) + documents = [guide["documents"][0]] * 2 if mode == "same_document" else guide["documents"] + responses = await asyncio.gather(*(upload(document) for document in documents)) + assert sorted(response.status_code for response in responses) == ( + [202, 413] if mode == "aggregate_limit" else [202, 202] + ), [response.text for response in responses] + complete = mode == "distinct" + assert len(deliveries) == int(complete) + if complete: + assert deliveries[0][0][-2:] == (guide["setup"]["id"], 1) + async with db_session.get_session_factory()() as session: + for model in (ArtifactPutAttempt, ArtifactReplica): + assert await session.scalar(select(func.count()).select_from(model)) == (2 if complete else 1) + run = await session.get(ProjectSetupRun, guide["setup"]["id"]) + assert run.status == ("queued" if complete else "awaiting_documents") + assert run.celery_task_id == (deliveries[0][1] if complete else None) + assert len(await _stored_keys(get_settings())) == (2 if complete else 1) + + +@pytest.mark.parametrize("source_mismatch", [False, True]) +async def test_replay_reauthorizes_each_original_action(project_client, monkeypatch, source_mismatch): + from app.modules.authorization.prepared import PreparedAuthorizationService + project = await create_project(project_client) + path = f"/api/v1/projects/{project['id']}/guides" + payload = {"version": "initial", "task_examples": [{"content": "Review a claim."}], + "documents": [{"label": "guide.pdf", "media_type": "application/pdf"}]} + headers = auth_headers() + first = await project_client.post(path, headers=headers, json=payload) + assert first.status_code == 201, first.text + calls = [] + prepare = PreparedAuthorizationService.prepare + consume = PreparedAuthorizationService.consume + async def observed_prepare(self, action, *args, **kwargs): + calls.append(("prepare", action.value)) + return await prepare(self, action, *args, **kwargs) + async def observed_consume(self, handle, action, *args, **kwargs): + calls.append(("consume", action.value)) + decision = await consume(self, handle, action, *args, **kwargs) + if source_mismatch and action.value == "project.guide_source_snapshot.create": + return decision.model_copy(update={"resource_context_digest": "sha256:" + "0" * 64}) + return decision + monkeypatch.setattr(PreparedAuthorizationService, "prepare", observed_prepare) + monkeypatch.setattr(PreparedAuthorizationService, "consume", observed_consume) + replay = await project_client.post(path, headers=headers, json=payload) + assert replay.status_code == (409 if source_mismatch else 201), replay.text + assert calls == [(phase, action) for action in ( + "project.guide.create", "project.guide_source_snapshot.create") + for phase in ("prepare", "consume")] + if source_mismatch: + assert "idempotency_mismatch" in replay.text + else: + assert replay.json() == first.json() + + +async def _create_documents(client, project, labels=("guide.pdf",)): + response = await client.post( + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), + json={"version": "initial", "task_examples": [{"content": "Review a claim."}], + "documents": [{"label": label, "media_type": "application/pdf"} for label in labels]}, + ) + assert response.status_code == 201, response.text + return response.json() + + +async def _assert_no_upload_effects(): + from sqlalchemy import func + from app.core.config import get_settings + from app.modules.artifacts.models import ArtifactPutAttempt, ArtifactReplica, ArtifactAdmissionCharge + async with db_session.get_session_factory()() as session: + for model in (ArtifactPutAttempt, ArtifactReplica, ArtifactAdmissionCharge): + assert await session.scalar(select(func.count()).select_from(model)) == 0 + runs = (await session.scalars(select(ProjectSetupRun))).all() + assert runs and all(run.status == "awaiting_documents" and run.celery_task_id is None for run in runs) + assert await _stored_keys(get_settings()) == set() + + +@pytest.mark.parametrize("selector", ["project", "guide", "document"]) +async def test_stored_foreign_document_selectors_are_concealed(project_client, selector): + from sqlalchemy import func + from app.modules.tasks.models import AuditEvent + first = await create_project(project_client, name="First project") + second = await create_project(project_client, name="Second project") + guides = [await _create_documents(project_client, project) for project in (first, second)] + selected = {"project": first["id"], "guide": guides[0]["id"], + "document": guides[0]["documents"][0]["document_id"]} + foreign = {"project": second["id"], "guide": guides[1]["id"], + "document": guides[1]["documents"][0]["document_id"]} + selected[selector] = foreign[selector] + async with db_session.get_session_factory()() as session: + before = await session.scalar(select(func.count()).select_from(AuditEvent)) + read = False + async def body(): + nonlocal read + read = True + raise AssertionError("foreign selectors must be rejected before body consumption") + yield b"" + response = await project_client.post( + f"/api/v1/projects/{selected['project']}/guides/{selected['guide']}/documents/{selected['document']}/content", + headers=auth_headers() | {"Content-Type": "application/pdf"}, content=body(), + ) + assert response.status_code == 404, response.text + assert not read + await _assert_no_upload_effects() + async with db_session.get_session_factory()() as session: + assert await session.scalar(select(func.count()).select_from(AuditEvent)) == before + + +@pytest.mark.parametrize("declared_length", [None, "1"]) +async def test_actual_stream_limit_rejects_without_admission(project_client, declared_length): + from app.adapters.artifacts import create_artifact_scratch_manager + app = project_client._transport.app + app.state.settings = app.state.settings.model_copy(update={"project_agent_max_document_bytes": 16}) + project = await create_project(project_client) + guide = await _create_documents(project_client, project) + consumed = [] + async def body(): + for chunk in (b"%PDF-1.7\n", b"a" * 16, b"must not be read"): + consumed.append(chunk) + yield chunk + headers = auth_headers() | {"Content-Type": "application/pdf"} + if declared_length is not None: + headers["Content-Length"] = declared_length + response = await project_client.post( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/documents/{guide['documents'][0]['document_id']}/content", + headers=headers, content=body(), + ) + assert response.status_code == 413, response.text + assert consumed == [b"%PDF-1.7\n", b"a" * 16] + await _assert_no_upload_effects() + manager = create_artifact_scratch_manager(app.state.settings) + try: + usage = await manager.usage() + assert usage.reservation_count == usage.reserved_bytes == 0 + finally: + manager.close() + + +@pytest.mark.parametrize("remaining_scope", [None, "other_project"]) +async def test_upload_rechecks_authority_before_body(project_client, remaining_scope): + from datetime import datetime, UTC + from app.modules.authorization.models import AdminRoleGrant + first = await create_project(project_client, name="Target project") + second = await create_project(project_client, name="Other project") + guide = await _create_documents(project_client, first) + await _create_documents(project_client, second) + async with db_session.get_session_factory()() as session: + grants = (await session.scalars(select(AdminRoleGrant).where( + AdminRoleGrant.role == "project_manager", AdminRoleGrant.status == "active"))).all() + assert len(grants) == 1 + grant = grants[0] + if remaining_scope: + session.add(AdminRoleGrant( + id=uuid4(), target_actor_profile_id=grant.target_actor_profile_id, + role="project_manager", scope_type="project", scope_project_id=second["id"], + status="active", version=1, + granted_by_actor_profile_id=grant.granted_by_actor_profile_id, + granted_by_admin_role_grant_id=grant.granted_by_admin_role_grant_id, + grant_reason="Authority only for the other persisted project", + )) + grant.status = "revoked" + grant.version += 1 + grant.revoked_by_actor_profile_id = grant.target_actor_profile_id + grant.revoked_by_admin_role_grant_id = grant.granted_by_admin_role_grant_id + grant.revoked_reason = "Upload authority proof" + grant.revoked_at = datetime.now(UTC) + await session.commit() + read = False + async def body(): + nonlocal read + read = True + raise AssertionError("unauthorized upload must not read the body") + yield b"" + response = await project_client.post( + f"/api/v1/projects/{first['id']}/guides/{guide['id']}/documents/{guide['documents'][0]['document_id']}/content", + headers=auth_headers() | {"Content-Type": "application/pdf"}, content=body(), + ) + assert response.status_code == 404, response.text + assert not read + await _assert_no_upload_effects() + + +@pytest.mark.parametrize("fault,mutant", [ + (None, False), ("missing_source", False), ("missing_source", True), + ("cross_key", False), ("cross_key", True), ("second_set", False), ("second_set", True), + *[(fault, mutant) for fault in ("setup_status", "setup_step", "setup_ready_at", "setup_celery") + for mutant in (False, True)], +]) +async def test_raw_sql_creation_pair_custody(project_client, monkeypatch, fault, mutant): + from sqlalchemy import text + from sqlalchemy.exc import IntegrityError + from projects.guide_creation_sql import ( + capture_creation_graph, insert_graph, malformed_graph, second_source_graph, + install_predicate_mutant, + ) + project = await create_project(project_client) + graph = await capture_creation_graph(project_client, project["id"], auth_headers(), monkeypatch) + async with db_session.get_engine().connect() as connection: + audit_before = await connection.scalar(text("select count(*) from audit_events")) + async with db_session.get_engine().connect() as connection: + transaction = await connection.begin() + try: + if mutant: + await install_predicate_mutant(connection, fault) + await insert_graph(connection, malformed_graph(graph, fault)) + if fault == "second_set": + await insert_graph(connection, second_source_graph(graph)) + if fault and not mutant: + with pytest.raises(IntegrityError, match="guide document creation pair is invalid") as error: + await connection.execute(text("set constraints require_document_creation_pair immediate")) + assert error.value.orig.sqlstate == "23514" + else: + await connection.execute(text("set constraints all immediate")) + expected_sets = 0 if fault == "missing_source" else 2 if fault == "second_set" else 1 + for table, expected in (("project_guides", 1), ("guide_source_snapshots", expected_sets), + ("guide_source_snapshot_items", expected_sets), + ("project_setup_runs", expected_sets), + ("guide_mutation_idempotency_records", 1 + expected_sets)): + assert await connection.scalar(text(f"select count(*) from {table}")) == expected + if fault == "cross_key": + assert await connection.scalar(text( + "select count(distinct idempotency_key) from guide_mutation_idempotency_records")) == 2 + if fault is None: + await transaction.commit() + finally: + if transaction.is_active: + await transaction.rollback() + async with db_session.get_engine().connect() as connection: + for table in ("project_guides", "guide_source_snapshots", "guide_source_snapshot_items", "project_setup_runs"): + assert await connection.scalar(text(f"select count(*) from {table}")) == (1 if fault is None else 0) + assert await connection.scalar(text("select count(*) from guide_mutation_idempotency_records")) == (2 if fault is None else 0) + assert await connection.scalar(text("select count(*) from audit_events")) == audit_before + (2 if fault is None else 0) + + +async def test_document_container_limit_rejects_before_admission(project_client): + import io + import zipfile + from app.modules.artifacts.guide_formats import GuideFormatLimits + content = io.BytesIO() + with zipfile.ZipFile(content, "w", compression=zipfile.ZIP_STORED) as archive: + for name in ("[Content_Types].xml", "_rels/.rels", "word/document.xml"): + archive.writestr(name, "") + for index in range(GuideFormatLimits().maximum_entries): + archive.writestr(f"word/part-{index}.xml", "") + project = await create_project(project_client) + media_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + response = await project_client.post( + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), + json={"version": "initial", "task_examples": [{"content": "Review a claim."}], + "documents": [{"label": "guide.docx", "media_type": media_type}]}, + ) + assert response.status_code == 201, response.text + guide = response.json() + upload = await project_client.post( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/documents/{guide['documents'][0]['document_id']}/content", + headers=auth_headers() | {"Content-Type": media_type}, content=content.getvalue(), + ) + assert upload.status_code == 422, upload.text + await _assert_no_upload_effects() diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index bcfde924d..0fc18ab5f 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -1,5 +1,7 @@ from __future__ import annotations +from project_create_fixtures import GUIDE_CREATION_CUSTODY_TRIGGERS + import asyncio import hashlib import inspect @@ -30,7 +32,7 @@ from app.db import session as db_session from app.db.base import Base from app.main import create_app -from app.modules.actors.models import ActorIdentityLink, ActorProfile, LegacyActorIdentity +from app.modules.actors.models import ActorIdentityLink, ActorProfile from app.modules.projects.models import ( EffectiveProjectSubmissionArtifactPolicy, GuideMutationIdempotencyRecord, @@ -88,7 +90,6 @@ from app.modules.projects.guide_mutation_service import GuideMutationService from app.modules.projects.repository import ProjectRepository, ProjectRepositoryIntegrityError from app.modules.projects.schemas import ( - GuideSourceSnapshotCreate, ProjectCreate, ProjectGuideCreate, ProjectGuideUpdate, @@ -129,8 +130,7 @@ complete_guide_payload, create_project, add_project_manager_admin_grant, - source_snapshot_payload, - create_source_snapshot, + read_guide_source_snapshot, create_guide, ) from projects.submission_policy_fixtures import ( @@ -1510,7 +1510,7 @@ async def revoke_system_project_manager_for_default_actor() -> None: await session.commit() -async def test_project_route_registers_project_manager_actor_without_auth_me( +async def test_project_route_uses_canonical_actor_profile( project_client: AsyncClient, ) -> None: response = await project_client.post( @@ -1530,16 +1530,10 @@ async def test_project_route_registers_project_manager_actor_without_auth_me( ) assert identity_link is not None profile = await session.get(ActorProfile, identity_link.actor_profile_id) - legacy_identity = await session.get( - LegacyActorIdentity, - identity_link.actor_profile_id, - ) assert profile is not None assert profile.actor_kind == "human" assert profile.status == "active" - assert legacy_identity is not None - assert legacy_identity.last_seen_roles == ["project_manager"] async def test_project_create_exact_replay_and_mismatch_are_atomic( @@ -1792,8 +1786,10 @@ def forbidden_runtime(*args): ) ).all() - assert snapshots == [] - assert setup_runs == [] + assert len(snapshots) == len(setup_runs) == 1 + assert setup_runs[0].source_snapshot_id == snapshots[0].id + assert setup_runs[0].status == "awaiting_documents" + assert setup_runs[0].celery_task_id is None assert reports == [] assert policies == [] @@ -1983,99 +1979,13 @@ async def test_project_identity_and_context_follow_exact_grant_and_lifecycle( assert denied.status_code == 404 -async def test_create_source_snapshot_waits_for_committed_documents_before_enqueue( - project_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Snapshot creation persists queued work without touching the broker.""" - project = await create_project(project_client) - def enqueue_failure( - *, - project_id: str, - guide_id: str, - source_snapshot_id: str, - setup_run_id: str, - setup_generation: int, - ) -> str: - """Simulate a broker outage after the guide transaction commits.""" - raise ProjectSetupQueueError("queue failed after commit") - monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "false") - get_settings.cache_clear() - monkeypatch.setattr( - project_setup_queue_module, - "enqueue_project_guide_compilation", - enqueue_failure, - ) - guide = await create_guide(project_client, project["id"], complete_guide_payload()) - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", - headers=auth_headers(), - json=source_snapshot_payload(), - ) - - assert response.status_code == 201, response.text - created_snapshot = response.json() - async with db_session.get_session_factory()() as session: - persisted_guide = await session.scalar( - select(ProjectGuide).where(ProjectGuide.id == guide["id"]) - ) - snapshot = await session.get(GuideSourceSnapshot, created_snapshot["id"]) - setup_run = await session.scalar( - select(ProjectSetupRun).where( - ProjectSetupRun.source_snapshot_id == created_snapshot["id"] - ) - ) - assert persisted_guide is not None - assert snapshot is not None - assert setup_run is not None - assert setup_run.status == "awaiting_documents" - assert setup_run.celery_task_id is None -async def test_create_source_snapshot_waits_for_committed_documents_before_broker_dispatch( - project_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Broker acceptance under another task id is not reported as an enqueue outage.""" - - def enqueue_with_wrong_identity(**_: object) -> str: - return str(uuid4()) - - monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "false") - get_settings.cache_clear() - monkeypatch.setattr( - project_setup_queue_module, - "enqueue_project_guide_compilation", - enqueue_with_wrong_identity, - ) - - project = await create_project(project_client) - guide = await create_guide(project_client, project["id"], complete_guide_payload()) - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", - headers=auth_headers(), - json=source_snapshot_payload(), - ) - - assert response.status_code == 201, response.text - async with db_session.get_session_factory()() as session: - setup_run = await session.scalar( - select(ProjectSetupRun).where( - ProjectSetupRun.source_snapshot_id == response.json()["id"] - ) - ) - - assert setup_run is not None - assert setup_run.status == "awaiting_documents" - assert setup_run.error_code is None - assert setup_run.celery_task_id is None - - -async def test_create_source_snapshot_does_not_run_agents_before_committed_documents( +async def test_read_guide_source_snapshot_does_not_run_agents_before_committed_documents( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -2084,7 +1994,7 @@ async def test_create_source_snapshot_does_not_run_agents_before_committed_docum project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - await create_source_snapshot(project_client, project["id"], guide["id"]) + await read_guide_source_snapshot(project["id"], guide["id"]) async with db_session.get_session_factory()() as session: snapshot = await session.scalar( @@ -2112,101 +2022,9 @@ async def test_create_source_snapshot_does_not_run_agents_before_committed_docum assert pre_submit_checker_policy is None -async def test_create_source_snapshot_autostart_waits_for_committed_documents( - project_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - enqueued: list[dict[str, object]] = [] - def capture_enqueue( - *, - project_id: str, - guide_id: str, - source_snapshot_id: str, - setup_run_id: str, - setup_generation: int, - ) -> str: - """Capture queue arguments without running Celery.""" - enqueued.append( - { - "project_id": project_id, - "guide_id": guide_id, - "source_snapshot_id": source_snapshot_id, - "setup_run_id": setup_run_id, - "setup_generation": setup_generation, - } - ) - return project_setup_identity.project_guide_compilation_task_id( - setup_run_id, setup_generation - ) - project = await create_project(project_client) - guide = await create_guide(project_client, project["id"], complete_guide_payload()) - - get_settings.cache_clear() - monkeypatch.setattr( - project_setup_queue_module, - "enqueue_project_guide_compilation", - capture_enqueue, - ) - - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) - - assert enqueued == [] - async with db_session.get_session_factory()() as session: - setup_runs = ( - await session.scalars( - select(ProjectSetupRun).where( - ProjectSetupRun.guide_id == guide["id"], - ProjectSetupRun.source_snapshot_id == snapshot["id"], - ) - ) - ).all() - assert len(setup_runs) == 1 - assert setup_runs[0].celery_task_id is None - - -async def test_create_source_snapshot_returns_created_when_post_commit_enqueue_fails( - project_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A late broker failure cannot turn a durable source snapshot create into a false 503.""" - project = await create_project(project_client) - guide = await create_guide(project_client, project["id"], complete_guide_payload()) - - def enqueue_failure( - *, - project_id: str, - guide_id: str, - source_snapshot_id: str, - setup_run_id: str, - setup_generation: int, - ) -> str: - """Simulate a broker outage after the snapshot transaction commits.""" - raise ProjectSetupQueueError("queue failed after commit") - - get_settings.cache_clear() - monkeypatch.setattr( - project_setup_queue_module, - "enqueue_project_guide_compilation", - enqueue_failure, - ) - - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", - headers=auth_headers(), - json=source_snapshot_payload(source_label="source-v2.md"), - ) - - assert response.status_code == 201, response.text - snapshot = response.json() - async with db_session.get_session_factory()() as session: - persisted_snapshot = await session.scalar( - select(GuideSourceSnapshot).where(GuideSourceSnapshot.id == snapshot["id"]) - ) - - assert persisted_snapshot is not None def sha256_hash(seed: str) -> str: @@ -2247,22 +2065,7 @@ async def test_guide_source_metadata_authority_records_exact_provenance_and_repl assert updated.status_code == 200, updated.text assert updated.json()["change_summary"] == "Expanded metadata." - snapshot_key = str(uuid4()) - snapshot_headers = auth_headers() | {"Idempotency-Key": snapshot_key} - snapshot_payload = source_snapshot_payload() - snapshotted = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", - headers=snapshot_headers, - json=snapshot_payload, - ) - snapshot_replay = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", - headers=snapshot_headers, - json=snapshot_payload, - ) - assert snapshotted.status_code == 201, snapshotted.text - assert snapshot_replay.status_code == 201, snapshot_replay.text - assert snapshot_replay.json() == snapshotted.json() + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) blocked = await project_client.patch( f"/api/v1/projects/{project['id']}/guides/{guide['id']}", @@ -2279,7 +2082,7 @@ async def test_guide_source_metadata_authority_records_exact_provenance_and_repl async with db_session.get_session_factory()() as session: persisted_guide = await session.get(ProjectGuide, guide["id"]) - persisted_snapshot = await session.get(GuideSourceSnapshot, snapshotted.json()["id"]) + persisted_snapshot = await session.get(GuideSourceSnapshot, snapshot["id"]) records = ( await session.scalars( @@ -2656,20 +2459,10 @@ async def consume(self, _handle, _action, _caller, resource): guide_id, ProjectGuideUpdate(change_summary="Clarified"), ) - snapshotted = await service.create_snapshot( - resolved, - prepared, - uuid4(), - project_id, - guide_id, - GuideSourceSnapshotCreate.model_validate(source_snapshot_payload()), - ) - - assert created.replayed is updated.replayed is snapshotted.replayed is False + assert created.replayed is updated.replayed is False assert updated.response.change_summary == "Clarified" - assert snapshotted.response.guide_id == str(guide_id) - assert snapshotted.response.items - assert snapshotted.setup_run_id == repository.setup_run.id + assert created.response.documents + assert created.setup_run_id == repository.setup_run.id assert prepared.prepare_count == prepared.consume_count == 3 assert len(replay.completed) == 3 assert session.flush_count == session.refresh_count == 1 @@ -2754,7 +2547,7 @@ async def test_guide_mutation_service_classifies_existing_replay() -> None: assert existing.replayed is True -async def test_guide_mutation_service_short_circuits_cached_operations() -> None: +async def test_guide_update_service_returns_exact_cached_response() -> None: resolved, project_id, _replay, _service = _guide_mutation_edge_subject() cached = SimpleNamespace(replayed=True) @@ -2764,16 +2557,6 @@ async def cached_existing(*_args): cached_service = GuideMutationService(object()) cached_service._existing = cached_existing # type: ignore[method-assign] guide_id = uuid4() - assert ( - await cached_service.create_guide( - resolved, - object(), - uuid4(), - project_id, - ProjectGuideCreate.model_validate(complete_guide_payload()), - ) - is cached - ) assert ( await cached_service.update_guide( resolved, @@ -2785,17 +2568,7 @@ async def cached_existing(*_args): ) is cached ) - assert ( - await cached_service.create_snapshot( - resolved, - object(), - uuid4(), - project_id, - guide_id, - GuideSourceSnapshotCreate.model_validate(source_snapshot_payload()), - ) - is cached - ) + def test_guide_mutation_service_classifies_reservation_outcomes() -> None: @@ -2941,8 +2714,8 @@ async def test_guide_source_metadata_authority_validates_key_before_actor_provis ), ( "post", - f"/api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots", - source_snapshot_payload(), + f"/api/v1/projects/{project_id}/guides/{guide_id}/documents/{uuid4()}/content", + {}, ), ) for method, path, payload in requests: @@ -3102,29 +2875,13 @@ async def test_guide_source_metadata_replay_cannot_cross_project_or_guide( assert crossed_update.status_code == 409 assert crossed_update.json()["error"]["code"] == "idempotency_mismatch" - snapshot_key = str(uuid4()) - snapshot_headers = auth_headers() | {"Idempotency-Key": snapshot_key} - snapshot_body = source_snapshot_payload() - first_snapshot = await project_client.post( - f"/api/v1/projects/{first_project['id']}/guides/{first_guide['id']}/source-snapshots", - headers=snapshot_headers, - json=snapshot_body, - ) - crossed_snapshot = await project_client.post( - f"/api/v1/projects/{second_project['id']}/guides/{second_guide['id']}/source-snapshots", - headers=snapshot_headers, - json=snapshot_body, - ) - assert first_snapshot.status_code == 201 - assert crossed_snapshot.status_code == 409 - assert crossed_snapshot.json()["error"]["code"] == "idempotency_mismatch" -async def test_guide_source_metadata_snapshot_replay_waits_for_committed_documents( +async def test_guide_creation_replay_waits_for_committed_documents( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: - """An exact snapshot replay returns custody without dispatching before verification.""" + """An exact guide replay returns custody without dispatching before verification.""" dispatched: list[dict[str, str]] = [] def capture_dispatch(**facts: str) -> str: @@ -3140,16 +2897,15 @@ def capture_dispatch(**facts: str) -> str: capture_dispatch, ) project = await create_project(project_client) - guide = await create_guide(project_client, project["id"], complete_guide_payload()) headers = auth_headers() | {"Idempotency-Key": str(uuid4())} - payload = source_snapshot_payload() + payload = complete_guide_payload() first = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", + f"/api/v1/projects/{project['id']}/guides", headers=headers, json=payload, ) replay = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", + f"/api/v1/projects/{project['id']}/guides", headers=headers, json=payload, ) @@ -3160,7 +2916,7 @@ def capture_dispatch(**facts: str) -> str: runs = ( await session.scalars( select(ProjectSetupRun).where( - ProjectSetupRun.source_snapshot_id == first.json()["id"] + ProjectSetupRun.id == first.json()["setup"]["id"] ) ) ).all() @@ -3208,7 +2964,7 @@ async def test_guide_source_metadata_database_rejects_unattributed_and_mismatche await session.commit() await session.rollback() - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) async with db_session.get_session_factory()() as session: persisted_snapshot = await session.get(GuideSourceSnapshot, snapshot["id"]) assert persisted_snapshot is not None @@ -3302,7 +3058,7 @@ async def test_project_setup_waits_for_verified_guide_material_before_outputs( project["id"], { **complete_guide_payload(), - "source_snapshot": source_snapshot_payload(), + "documents": complete_guide_payload()["documents"], }, ) @@ -3375,7 +3131,7 @@ def fail_enqueue(**_: object) -> str: project["id"], { **complete_guide_payload(), - "source_snapshot": source_snapshot_payload(), + "documents": complete_guide_payload()["documents"], }, ) @@ -3444,7 +3200,7 @@ async def test_dispatch_pending_republishes_only_after_stale_cutoff( guide = await create_guide( project_client, project["id"], - {**complete_guide_payload(), "source_snapshot": source_snapshot_payload()}, + {**complete_guide_payload(), "documents": complete_guide_payload()["documents"]}, ) published: list[str | None] = [] @@ -3561,7 +3317,7 @@ async def test_project_setup_visibility_apis_require_active_local_grant( project["id"], { **complete_guide_payload(), - "source_snapshot": source_snapshot_payload(), + "documents": complete_guide_payload()["documents"], }, ) setup_run_response = await project_client.get( @@ -3720,7 +3476,7 @@ async def test_removed_sufficiency_agent_route_has_no_effects( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) async with db_session.get_session_factory()() as session: before = await session.scalar(select(func.count()).select_from(AuditEvent)) response = await project_client.post( @@ -3783,32 +3539,28 @@ async def test_project_guide_update_rejects_unknown_non_contract_fields( -async def test_source_snapshot_requires_at_least_one_uploaded_source_item( +async def test_guide_documents_requires_at_least_one_uploaded_source_item( project_client: AsyncClient, ) -> None: project = await create_project(project_client) - guide = await create_guide(project_client, project["id"], complete_guide_payload()) response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), - json={"items": []}, + json={**complete_guide_payload(), "documents": []}, ) assert response.status_code == 422 - assert response.json()["detail"][0]["loc"] == ["body", "items"] + assert response.json()["detail"][0]["loc"] == ["body", "documents"] -async def test_source_snapshot_rejects_unsafe_refs(project_client: AsyncClient) -> None: +async def test_guide_documents_rejects_unsafe_refs(project_client: AsyncClient) -> None: project = await create_project(project_client) - guide = await create_guide(project_client, project["id"], complete_guide_payload()) response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), - json=source_snapshot_payload( - source_label="https://docs.flow.test/guide.md?X-Amz-Signature=secret" - ), + json={**complete_guide_payload(), "documents": [{"label": "https://docs.flow.test/guide.md?X-Amz-Signature=secret", "media_type": "application/pdf"}]}, ) assert response.status_code == 422 @@ -3823,19 +3575,14 @@ async def test_source_snapshot_rejects_unsafe_refs(project_client: AsyncClient) "credentialing-guide.md", ], ) -async def test_source_snapshot_allows_non_secret_keyword_prefixes( +async def test_guide_documents_allows_non_secret_keyword_prefixes( project_client: AsyncClient, source_label: str, ) -> None: project = await create_project(project_client) - guide = await create_guide(project_client, project["id"], complete_guide_payload()) + guide = await create_guide(project_client, project["id"], {**complete_guide_payload(), "documents": [{"label": source_label, "media_type": "application/pdf"}]}) - snapshot = await create_source_snapshot( - project_client, - project["id"], - guide["id"], - payload=source_snapshot_payload(source_label=source_label), - ) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) assert source_label in {item["source_label"] for item in snapshot["items"]} @@ -3890,33 +3637,31 @@ async def test_source_snapshot_allows_non_secret_keyword_prefixes( "inline:/mnt/material/guide.md", ], ) -async def test_source_snapshot_rejects_credential_and_local_refs( +async def test_guide_documents_rejects_credential_and_local_refs( project_client: AsyncClient, source_label: str, ) -> None: project = await create_project(project_client) - guide = await create_guide(project_client, project["id"], complete_guide_payload()) response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), - json=source_snapshot_payload(source_label=source_label), + json={**complete_guide_payload(), "documents": [{"label": source_label, "media_type": "application/pdf"}]}, ) assert response.status_code == 422 assert "locator or credential material" in response.json()["detail"] -async def test_source_snapshot_rejects_unsafe_content_cid( +async def test_guide_documents_rejects_unsafe_content_cid( project_client: AsyncClient, ) -> None: project = await create_project(project_client) - guide = await create_guide(project_client, project["id"], complete_guide_payload()) - payload = source_snapshot_payload() - payload["items"][0]["content_cid"] = "https://storage.flow.test/doc?token=secret" + payload = complete_guide_payload() + payload["documents"][0]["content_cid"] = "https://storage.flow.test/doc?token=secret" response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), json=payload, ) @@ -3925,17 +3670,15 @@ async def test_source_snapshot_rejects_unsafe_content_cid( assert "extra" in response.text -async def test_source_snapshot_rejects_duplicate_source_items( +async def test_guide_documents_rejects_duplicate_source_items( project_client: AsyncClient, ) -> None: project = await create_project(project_client) - guide = await create_guide(project_client, project["id"], complete_guide_payload()) - payload = source_snapshot_payload() - payload["items"][1]["source_kind"] = payload["items"][0]["source_kind"] - payload["items"][1]["source_label"] = payload["items"][0]["source_label"] + payload = complete_guide_payload() + payload["documents"][1]["label"] = payload["documents"][0]["label"] response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), json=payload, ) @@ -3944,22 +3687,21 @@ async def test_source_snapshot_rejects_duplicate_source_items( assert "duplicate source item" in response.json()["detail"] -async def test_source_snapshot_rejects_unknown_request_fields( +async def test_guide_documents_rejects_unknown_request_fields( project_client: AsyncClient, ) -> None: project = await create_project(project_client) - guide = await create_guide(project_client, project["id"], complete_guide_payload()) - top_level_payload = {**source_snapshot_payload(), "client_note": "not allowed"} - item_payload = source_snapshot_payload() - item_payload["items"][0]["signed_url"] = "not allowed" + top_level_payload = {**complete_guide_payload(), "client_note": "not allowed"} + item_payload = complete_guide_payload() + item_payload["documents"][0]["signed_url"] = "not allowed" top_level_response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), json=top_level_payload, ) item_response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), json=item_payload, ) @@ -3970,15 +3712,15 @@ async def test_source_snapshot_rejects_unknown_request_fields( assert "extra" in item_response.text -async def test_source_snapshot_rejects_oversized_source_fields( +async def test_guide_documents_rejects_oversized_source_fields( project_client: AsyncClient, ) -> None: project = await create_project(project_client) - guide = await create_guide(project_client, project["id"], complete_guide_payload()) - payload = source_snapshot_payload(source_label="a" * 501) + payload = complete_guide_payload() + payload["documents"][0]["label"] = "a" * 501 response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), json=payload, ) @@ -3992,7 +3734,7 @@ async def test_sufficiency_report_rejects_snapshot_manifest_hash_drift( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) async with db_session.get_session_factory()() as session: persisted = await session.get(GuideSourceSnapshot, snapshot["id"]) @@ -4007,7 +3749,7 @@ async def test_submission_policy_rejects_snapshot_item_drift( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) async with db_session.get_session_factory()() as session: item = await session.scalar( select(GuideSourceSnapshotItem) @@ -4052,43 +3794,6 @@ async def test_submission_policy_rejects_snapshot_item_drift( await session.commit() -async def test_snapshot_freshness_fails_closed_when_captured_at_ties( - project_client: AsyncClient, -) -> None: - project = await create_project(project_client) - guide = await create_guide(project_client, project["id"], complete_guide_payload()) - first_snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) - second_response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", - headers=auth_headers(), - json=source_snapshot_payload(source_label="guide-v2.md"), - ) - assert second_response.status_code == 201, second_response.text - second_snapshot = second_response.json() - tied_at = datetime(2026, 6, 27, 12, 0, tzinfo=UTC) - - async with db_session.get_session_factory()() as session: - first = await session.get(GuideSourceSnapshot, first_snapshot["id"]) - second = await session.get(GuideSourceSnapshot, second_snapshot["id"]) - assert first is not None - assert second is not None - first.captured_at = tied_at - second.captured_at = tied_at - await session.commit() - - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/sufficiency-reports", - headers=auth_headers(), - json={ - "source_snapshot_id": second_snapshot["id"], - "status": "passed", - "findings": [], - "summary": "Guide reviewed.", - }, - ) - - assert response.status_code == 422 - assert "ambiguous" in response.json()["detail"] async def test_sufficiency_report_rejects_unknown_request_fields( @@ -4096,7 +3801,7 @@ async def test_sufficiency_report_rejects_unknown_request_fields( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) top_level_response = await project_client.post( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/sufficiency-reports", @@ -4147,7 +3852,7 @@ async def test_sufficiency_report_status_requires_matching_findings( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) response = await project_client.post( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/sufficiency-reports", @@ -4169,7 +3874,7 @@ async def test_manual_sufficiency_report_rejects_agent_provenance_fields( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) rejected = await project_client.post( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/sufficiency-reports", @@ -4205,7 +3910,7 @@ async def test_manual_sufficiency_report_exact_replay_reauthorizes_and_mismatch_ ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) endpoint = f"/api/v1/projects/{project['id']}/guides/{guide['id']}/sufficiency-reports" headers = auth_headers() payload = { @@ -4286,19 +3991,11 @@ async def test_submission_artifact_policy_replay_postgres_converges_exact_reserv ), ids, ) - for table, trigger in ( - ("project_guides", "guide_mutation_product_custody"), - ("project_guides", "guide_task_examples_create_custody"), - ("guide_source_snapshots", "source_snapshot_product_custody"), - ): + for table, trigger in GUIDE_CREATION_CUSTODY_TRIGGERS: await connection.execute(text(f"alter table {table} disable trigger {trigger}")) await seed_guide_snapshot_rows(connection, project_id=ids["project"], guide_id=ids["guide"], version="v1", snapshot_id=ids["snapshot"]) - for table, trigger in ( - ("project_guides", "guide_mutation_product_custody"), - ("project_guides", "guide_task_examples_create_custody"), - ("guide_source_snapshots", "source_snapshot_product_custody"), - ): + for table, trigger in GUIDE_CREATION_CUSTODY_TRIGGERS: await connection.execute(text(f"alter table {table} enable trigger {trigger}")) operation_id, policy_id, key = uuid4(), str(uuid4()), uuid4() @@ -4352,18 +4049,18 @@ async def reserve_second(): await engine.dispose() -async def test_source_snapshot_manifest_cannot_be_rewritten_for_legacy_shape( +async def test_source_snapshot_manifest_rejects_caller_storage_references( project_client: AsyncClient, ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) async with db_session.get_session_factory()() as session: persisted = await session.get(GuideSourceSnapshot, snapshot["id"]) assert persisted is not None manifest = json.loads(json.dumps(persisted.manifest_json)) for item in manifest["items"]: - item["durable_ref"] = "caller-owned://legacy-source" + item["durable_ref"] = "caller-owned://untrusted-source" item["content_hash"] = "sha256:" + ("0" * 64) with pytest.raises(IntegrityError): await session.execute( @@ -4396,7 +4093,7 @@ async def test_manual_submission_artifact_policy_rejects_agent_provenance_fields ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) create_response = await project_client.post( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", @@ -4484,7 +4181,7 @@ async def test_agent_derived_policy_approval_revalidates_server_owned_provenance ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report( project_client, project["id"], @@ -4531,7 +4228,7 @@ async def test_submission_artifact_policy_removed_agent_route_performs_no_runtim ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) endpoint = ( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots/" f"{snapshot['id']}/derive-submission-artifact-policy" @@ -4563,7 +4260,7 @@ async def test_submission_artifact_policy_approval_persists_effective_policy_has ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy = await create_submission_artifact_policy( project_client, @@ -4623,7 +4320,7 @@ async def test_submission_artifact_policy_approval_rejects_body_hash_mismatch( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy = await create_submission_artifact_policy( project_client, @@ -4656,7 +4353,7 @@ async def test_approved_submission_artifact_policy_cannot_be_updated( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy = await create_submission_artifact_policy( project_client, @@ -4691,7 +4388,7 @@ async def test_submission_artifact_policy_creation_requires_sufficiency_report( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) response = await project_client.post( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", headers=auth_headers(), @@ -4713,7 +4410,7 @@ async def test_submission_artifact_policy_create_rejects_diagnostic_only_suffici """A human diagnostic report cannot substitute for setup-owned sufficiency.""" project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) response = await project_client.post( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", @@ -4734,7 +4431,7 @@ async def test_submission_artifact_policy_create_rejects_unacknowledged_warning_ """An authoritative warning result without exact 12E acknowledgement cannot create policy.""" project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) diagnostic = await create_sufficiency_report( project_client, project["id"], @@ -4762,7 +4459,7 @@ async def test_submission_artifact_policy_create_exact_idempotency_replay_is_sta """Exact create replay returns the committed response and creates one row.""" project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) diagnostic = await create_sufficiency_report( project_client, project["id"], guide["id"], snapshot["id"] ) @@ -4801,7 +4498,7 @@ async def test_submission_artifact_policy_create_fault_rolls_back_atomic_boundar """Every named post-authorization fault leaves no policy, replay, or allow evidence.""" project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) diagnostic = await create_sufficiency_report( project_client, project["id"], guide["id"], snapshot["id"] ) @@ -4897,7 +4594,7 @@ async def test_database_enforces_effective_policy_submission_policy_hash( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy = await create_submission_artifact_policy( project_client, @@ -4925,7 +4622,7 @@ async def test_database_enforces_pre_submit_checker_effective_policy_hash( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy = await create_submission_artifact_policy( project_client, @@ -4957,7 +4654,7 @@ async def test_submission_artifact_policy_approval_merges_packaging_rules( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy = await create_submission_artifact_policy( project_client, @@ -4992,7 +4689,7 @@ async def test_approved_submission_artifact_policy_is_immutable( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy = await create_submission_artifact_policy( project_client, @@ -5024,7 +4721,7 @@ async def test_draft_submission_artifact_policy_can_be_updated( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy = await create_submission_artifact_policy( project_client, @@ -5078,7 +4775,7 @@ async def test_submission_artifact_policy_update_rejects_stale_cas_without_succe """A stale predecessor digest creates no replacement or supersession.""" project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy = await create_submission_artifact_policy( project_client, project["id"], guide["id"], snapshot["id"] @@ -5116,9 +4813,7 @@ async def test_submission_artifact_policy_update_conceals_foreign_policy_id( """A policy selected through another project or guide is indistinguishable from absent.""" first_project = await create_project(project_client) first_guide = await create_guide(project_client, first_project["id"], complete_guide_payload()) - first_snapshot = await create_source_snapshot( - project_client, first_project["id"], first_guide["id"] - ) + first_snapshot = await read_guide_source_snapshot(first_project["id"], first_guide["id"]) await create_sufficiency_report( project_client, first_project["id"], first_guide["id"], first_snapshot["id"] ) @@ -5152,7 +4847,7 @@ async def test_submission_artifact_policy_update_fault_rolls_back_replacement( """A post-supersession fault restores the draft and all update boundary state.""" project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy = await create_submission_artifact_policy( project_client, project["id"], guide["id"], snapshot["id"] @@ -5219,7 +4914,7 @@ async def test_submission_artifact_policy_update_concurrent_cas_creates_one_succ """Two replacement attempts against one draft converge on one append-only winner.""" project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy = await create_submission_artifact_policy( project_client, project["id"], guide["id"], snapshot["id"] @@ -5263,7 +4958,7 @@ async def test_approving_replacement_policy_supersedes_prior_rows( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) first_policy = await create_submission_artifact_policy( project_client, @@ -5373,7 +5068,7 @@ async def test_approving_replacement_policy_with_same_effective_content_succeeds ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy_body = project_submission_artifact_policy_body() first_policy = await create_submission_artifact_policy( @@ -5414,7 +5109,7 @@ async def test_replacement_policy_requires_complete_prior_effective_context( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) first_policy = await create_submission_artifact_policy( project_client, @@ -5469,7 +5164,7 @@ async def test_concurrent_policy_approvals_do_not_fork_current_chain( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) first_policy = await create_submission_artifact_policy( project_client, @@ -5586,7 +5281,7 @@ async def test_inline_guide_body_is_rejected_after_source_snapshot( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - await create_source_snapshot(project_client, project["id"], guide["id"]) + await read_guide_source_snapshot(project["id"], guide["id"]) response = await project_client.patch( f"/api/v1/projects/{project['id']}/guides/{guide['id']}", @@ -5603,7 +5298,7 @@ async def test_removed_payment_policy_edit_after_source_snapshot_is_rejected( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - await create_source_snapshot(project_client, project["id"], guide["id"]) + await read_guide_source_snapshot(project["id"], guide["id"]) payment_policy = { "base_amount": "25.00", "currency": "USD", @@ -5629,7 +5324,7 @@ async def test_draft_policy_cannot_be_approved_after_guide_activation( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) report = await create_sufficiency_report( project_client, project["id"], @@ -5690,7 +5385,7 @@ async def test_manual_submission_artifact_policy_create_rejects_default_weakenin ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) response = await project_client.post( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", @@ -5731,7 +5426,7 @@ async def test_submission_artifact_policy_rejects_default_artifact_key_conflict( ) project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) response = await project_client.post( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", @@ -5766,7 +5461,7 @@ async def test_submission_artifact_policy_dedupes_identical_default_artifact_key ) project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy = await create_submission_artifact_policy( project_client, @@ -5792,7 +5487,7 @@ async def test_submission_artifact_policy_rejects_rule_hash_weakening( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) response = await project_client.post( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", @@ -5815,7 +5510,7 @@ async def test_submission_artifact_policy_rejects_arbitrary_packaging_refs( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) response = await project_client.post( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", @@ -5875,7 +5570,7 @@ async def test_submission_artifact_policy_rejects_unknown_policy_keys( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) response = await project_client.post( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", @@ -5896,7 +5591,7 @@ async def test_submission_artifact_policy_rejects_unknown_wrapper_fields( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy = await create_submission_artifact_policy( project_client, @@ -5965,7 +5660,7 @@ async def test_submission_artifact_policy_rejects_forbidden_required_artifacts( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) response = await project_client.post( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", @@ -6123,7 +5818,7 @@ async def test_submission_artifact_policy_rejects_ambiguous_or_oversized_policy_ ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) response = await project_client.post( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", @@ -6144,7 +5839,7 @@ async def test_blocking_sufficiency_report_prevents_policy_creation( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report( project_client, project["id"], @@ -6172,7 +5867,7 @@ async def test_unified_warnings_do_not_use_the_manual_report_acknowledgement_pat ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) diagnostic = await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"], status="passed_with_warnings") compiled_id = await create_compiled_report_fixture(diagnostic["id"], snapshot["id"]) @@ -6210,7 +5905,7 @@ async def test_sufficiency_warning_acknowledgement_requires_setup_role_for_polic ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) report = await create_sufficiency_report( project_client, project["id"], @@ -6251,7 +5946,7 @@ async def test_sufficiency_warning_acknowledgement_rejects_unknown_fields( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) report = await create_sufficiency_report( project_client, project["id"], @@ -6277,7 +5972,7 @@ async def test_worker_cannot_approve_submission_artifact_policy( ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + snapshot = await read_guide_source_snapshot(project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) policy = await create_submission_artifact_policy( project_client, @@ -6890,7 +6585,7 @@ async def test_project_create_copied_key_cannot_cross_actor_namespace( second_subject = f"copied-key-actor-{uuid4()}" monkeypatch.setenv("WORKSTREAM_DEV_AUTH_SUBJECT", second_subject) get_settings.cache_clear() - admitted = await project_client.get("/api/v1/auth/me", headers=auth_headers()) + admitted = await project_client.get("/api/v1/actors/me", headers=auth_headers()) assert admitted.status_code == 200 grantor_id, _, grantor_grant_id = await ensure_access_administrator_bootstrap() async with db_session.get_session_factory()() as session: diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index dd75a6d5c..d2ae439b4 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -1026,22 +1026,9 @@ async def create_policy_bundle_for_guide( await session.flush() await session.commit() - snapshot_response = await client.post( - f"/api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots", - headers=auth_headers(), - json={ - "items": [ - { - "source_kind": "document", - "source_label": f"guide-{guide_id}.pdf", - "ingestion_adapter": "upload", - "media_type": "application/pdf", - } - ] - }, - ) - assert snapshot_response.status_code == 201, snapshot_response.text - snapshot = snapshot_response.json() + from projects.guide_fixtures import read_guide_source_snapshot + + snapshot = await read_guide_source_snapshot(project_id, guide_id) async with db_session.get_session_factory()() as session: setup = await session.scalar( select(ProjectSetupRun).where( diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 99c096500..43034c5a1 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -317,7 +317,9 @@ The `guide_source_snapshot.task_examples` manifest contains the example hash and count, the server-owned snapshot id and generation plus each server-owned item id/order and its non-authoritative source metadata. Caller hashes, content identifiers, excerpts, provider references, and fetch locators -are excluded. Changing a declaration creates a new snapshot and setup generation. +are excluded. Each guide has one declared document set. Changing a declaration, +document bytes or task examples requires a new guide version, which receives +its own internal snapshot and initial setup. ## GuideSourceSnapshotItem @@ -341,12 +343,11 @@ keys or credentials. No URL fetching, Markdown body, or extraction continuation is part of this path. File access and provider allocations retain exact original identity under the runtime custody contracts below. -Any guide or source-material change creates a new source snapshot. That -invalidates prior sufficiency reports, derived policies, effective policies, -checker bundles, acknowledgements, and approvals for activation. -A new guide-source snapshot invalidates prior setup records for new activation -and unlocked tasks only. Tasks already locked to an earlier snapshot retain -that policy context unless an explicit audited rebase occurs. +A new guide version requires its own sufficiency findings, policy proposals, +acknowledgements and approvals before activation. Prior immutable evidence is +retained and cannot substitute for the new version's evidence. Tasks already +locked to an earlier guide and snapshot retain that policy context unless an +explicit audited rebase occurs. ## ProjectSetupRun diff --git a/docs/decision_0011_submission_artifact_policy_drives_pre_submit.md b/docs/decision_0011_submission_artifact_policy_drives_pre_submit.md index 1f8fa36aa..aa2230b82 100644 --- a/docs/decision_0011_submission_artifact_policy_drives_pre_submit.md +++ b/docs/decision_0011_submission_artifact_policy_drives_pre_submit.md @@ -53,9 +53,9 @@ are excluded. Non-finite numbers such as `NaN` or `Infinity` are rejected before hashing. The manifest builder rejects duplicate `(source_kind, source_label)` pairs before hashing and assigns server-owned item IDs and orders. Integrity validation and database constraints reject duplicate IDs or orders later. -Changing the declared document set creates a new snapshot and setup generation. -Task examples are immutable for their guide version; changing them requires a -new guide version. Replacing document material or examples invalidates prior +The declared document set, committed originals and task examples are immutable +for their guide version. Changing any of them requires a new guide version, +which receives its own internal snapshot and initial setup. Replacing document material or examples invalidates prior sufficiency reports, derived policies, effective policies, checker specs, checker bundles, acknowledgements, and approvals for activation. diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index c10ac6a98..6771abe6a 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -109,14 +109,15 @@ python3 scripts/check_markdown_links.py git diff --check ``` -Use `/api/v1/actors/me` for canonical actor profiles and -`PATCH /api/v1/actors/me` for human-owned display data. The still-present -`/api/v1/auth/me` route is obsolete and is being removed by the separate guide -work; it is not an alternative profile or authority contract. Task -claim/start/work-context require current canonical authority and the applicable +Use `GET /api/v1/actors/me` for canonical actor self-read. The duplicate +`GET /api/v1/auth/me` endpoint is removed. Actor admission does not copy issuer +email or display name into the profile. Consumers must not treat token identity +metadata or workflow eligibility as profile or authorization truth. Human-owned +display data is written only through `PATCH /api/v1/actors/me`. +Task claim/start/work-context require current canonical authority and the applicable exact project grant. Token claims and eligibility rows are not authority. -The API drill now ends its task journey at that supported -public boundary; hidden submission creation has separate proof. +The API drill ends its task journey at that supported public boundary; hidden +submission creation has separate proof. ## Request And Error Context @@ -1125,25 +1126,33 @@ Project Manager grants cannot create projects. ## Draft guide and source-metadata authorization -`POST /api/v1/projects/{project_id}/guides`, its draft-guide `PATCH`, and the -source-snapshot metadata `POST` each require a UUID `Idempotency-Key` and an -active system or exact-project Project Manager grant. A 403 for an existing -project is expected when that local grant is absent, revoked, stale, or scoped -to another project; do not restore access from token roles or issuer claims. -Use the request/correlation IDs to inspect the bounded denial event. - -Guide creation must not create source snapshots, policy rows, or setup runs. -Source-snapshot creation is the separate boundary that may atomically commit -one setup-run queue intent. Celery receives identifiers only after commit; a -prepared authorization handle must never appear in task arguments, logs, or -serialized state. If broker dispatch fails, inspect the exact setup run for -`enqueue_failed` and use its bounded recovery path. Retrying the original HTTP -request returns its recorded response and must not dispatch again. - -Guide creation requires the ordered task-example list. It is stored with guide -metadata in PostgreSQL and cannot be edited in place; corrections require a new -guide version. Upload-only document declarations form the separate immutable -source snapshot, and original document bytes live in ArtifactStore/S3. Inline +`POST /api/v1/projects/{project_id}/guides` and its draft-guide `PATCH` +require a UUID `Idempotency-Key` and an active system or exact-project Project +Manager grant. A 403 for an existing project is expected when that local grant +is absent, revoked, stale, or scoped to another project; do not restore access +from token roles or issuer claims. Use request/correlation IDs to inspect the +bounded denial event. + +Guide creation commits guide metadata, the complete declared document set and +one `awaiting_documents` setup together. Its transaction consumes distinct +internal guide-create and source-consent decisions and commits their paired +replay records using the same external key. There is no public source-snapshot +creation operation. Exact create replay rechecks current authority for both +decisions and returns the original document IDs and initial setup response. + +Upload each declared document through +`POST /api/v1/projects/{project_id}/guides/{guide_id}/documents/{document_id}/content` +with its declared content type and a UUID replay key. Exact membership and +current ingest authority are checked before reading bytes. Only committed +readiness of every declared document can dispatch setup. Celery receives +identifiers after commit; prepared authorization handles must never appear in +task arguments, logs or serialized state. The existing bounded continuation +and dispatch recovery paths handle post-commit failures without another upload +or compilation attempt. + +The ordered task-example list and document declarations are immutable guide +metadata in PostgreSQL; changes require a new guide version. Original document +bytes live in ArtifactStore/S3. Inline Markdown and URL/repository ingestion are unavailable. Bounded draft metadata such as `change_summary` may still be updated. Embedded review, revision, retired payout/economic, and contribution-record configuration fields correctly return 422; do not reintroduce a diff --git a/docs/operations_project_operating_manual.md b/docs/operations_project_operating_manual.md index 9e1f64b0b..583b72e90 100644 --- a/docs/operations_project_operating_manual.md +++ b/docs/operations_project_operating_manual.md @@ -28,9 +28,10 @@ Target v0.1 checklist before releasing tasks. The remaining setup/activation work: - project name and slug exist -- project guide metadata created with at least one ordinary-text task example -- every assigned PDF/DOCX/PPTX guide original uploaded to ArtifactStore/S3 -- guide source snapshot captured +- project guide metadata created with at least one ordinary-text task example and + the complete PDF/DOCX/PPTX document list +- every declared original uploaded to ArtifactStore/S3 using its returned document ID +- automatic setup starts after all declared documents have committed bytes - project owner setup material captured - latest project setup run visible through covered Project Manager or authorized Operator/Audit projection @@ -74,7 +75,9 @@ it does not require a human reviewer pool, lease or decision endpoint. Unsupported false activation is rejected rather than silently switched to true. See the [implementation handoff](../.commitrail/changes/pre-review-plan-reconciliation.md#product-builder-handoff-implement-the-setting-next). -The guide source snapshot freezes guide/source material only. While the guide is +Guide creation freezes the declared document set internally; there is no separate +public source-snapshot creation step. The internal guide source snapshot freezes the declared document metadata and +the guide task-example hash/count commitment. While the guide is still draft, an authorized covered Project Manager may attach or update review and revision policy records after snapshot capture because those records are activated as separate guide-policy context. Contribution policy is project-level diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 94e8e25a3..6921e8709 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -131,9 +131,9 @@ cannot be reused as post-submission review-gate evidence. See the | Lifecycle stage | Status on `main` | What is already proven | What remains before v0.1 | | --- | --- | --- | --- | -| Identity and actor resolution | **Live foundation** | Flow-token verification; canonical ActorProfile and ActorIdentityLink; human/service separation; lifecycle controls | Final end-to-end operational and conformance proof | +| Identity and actor resolution | **Live foundation** | Flow-token verification; canonical ActorProfile and ActorIdentityLink; human/service separation; lifecycle controls; canonical `/actors/me` self-read with duplicate `/auth/me` removed | Final end-to-end operational and conformance proof | | Authorization kernel | **Live foundation** | Closed action/permission catalogues; deny-by-default evaluation; grants; fixed services; rate controls; opaque transaction-bound PREP; atomic decision evidence | Activate only the remaining owner-proven TASK, checker, REV, and CON boundaries; remove obsolete authority after replacement paths are live | -| Project Guide source custody | **Live foundation** | Project Manager original-document uploads; immutable metadata snapshots; exact run-scoped reads; S3-backed originals and isolated agent document inspection | Carry the same document generation through manager approval and guide activation; prove each enabled document reader | +| Project Guide source custody | **Live foundation** | Guide creation declares documents and task examples; public document upload; immutable internal metadata snapshots; exact run-scoped reads; S3-backed originals and isolated agent document inspection | Carry the same document generation through manager approval and guide activation; prove each enabled document reader | | Unified Project Guide compilation | **Live automatic draft/findings setup** | Committed original-document readiness dispatches one immutable attempt through Celery; complete result and crash/recovery custody; distinct pre/post proposals; deterministic sufficiency and submission-artifact-policy projections; immutable authorized setup finalization | Add manager proposal review, correction, approval and manual rerun; add deterministic post-submit projection and one checker-service port | | Contribution policy administration | **Hidden and proven** | Finance Authority adapter-binding lifecycle; ContributionPolicy read/create/update/publish/retire with exact Finance Authority; immutable operation and event history | Expose selected-policy validation, bind one published complete version to the active guide generation | | Task readiness and claim | **Foundation with grant-backed contributor commands** | Task records, assignments and locked work context; claim/start/contributor context use exact-project Submitter grants; separate manager context and system-Operator start | Bind the guide's ContributionPolicyVersion before `READY` and carry it through TaskAssignment without a current-policy lookup; finish ready queues, remaining management/read authority and durable assignment invalidation | @@ -210,7 +210,9 @@ cannot be reused as post-submission review-gate evidence. See the permitted to manage. - Guide originals remain immutable in ArtifactStore; PostgreSQL holds metadata, versions, custody and the required task-example list. Guide creation requires - at least one nonblank example; a starting idea is sufficient and optional + at least one nonblank example and the complete nonempty document list. The + response supplies document IDs for the public binary upload route; no separate + source-snapshot creation call remains. A starting idea is sufficient and optional example fields do not repeat requirements from the guide. Each snapshot/run binds the exact version's examples. Upload admission checks bounded format, digest and size. Committed originals do not bypass the separate verification required for @@ -457,7 +459,7 @@ reader does not need internal engineering records to understand the roadmap above. The main remaining trace sequence is: -- Unified guide: `POL-04B1 -> POL-04B -> POL-05A -> AUTH-12F4 -> POL-05B -> POL-06A +- Unified guide: `POL-04B1 -> POL-04B -> POL-04B2 -> POL-05A -> AUTH-12F4 -> POL-05B -> POL-06A -> AUTH-12G -> POL-06B -> POL-07 -> AUTH-12H`. `ARCH-04A` catalogue/schema reconciliation precedes approval-eligible `POL-04B` generations, and actual selected-capability conformance precedes `POL-07`/activation. POL-05 includes diff --git a/docs/spec_artifact_storage_service.md b/docs/spec_artifact_storage_service.md index fe3548d52..bd7d2fffe 100644 --- a/docs/spec_artifact_storage_service.md +++ b/docs/spec_artifact_storage_service.md @@ -989,8 +989,9 @@ regenerate bytes or replay a mutation. If authoritative absence is confirmed and the owning durable source can be regenerated, the original caller may prepare it again and compare the complete digest/size. Identical bytes may replay the original operation after admission capacity is reacquired. Changed -bytes abandon the old operation and create a new source snapshot/setup -generation or checker-run attempt; they never reuse the old operation, snapshot, +guide bytes require a new guide version with its own document declaration, +internal snapshot and setup; changed checker output requires a new checker-run +attempt. Neither reuses the old operation, snapshot, or binding identity. A generator that cannot reproduce exact bytes fails its old infrastructure attempt instead of fabricating replay. Bytes are never placed in PostgreSQL, Redis, Celery payloads, logs, or audit. diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index fef4bf605..82416038f 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -50,9 +50,9 @@ gRPC, or asynchronous transport without changing product authority semantics. It contains no Workstream product role or permission. Email, display name, skills, reputation, and relationship metadata are never authorization keys. -During the compatibility period, `/api/v1/auth/me` and actor registration do -not copy issuer email or display name, and those response fields remain null. -Canonical profile metadata is owned by the later actor-profile migration. +Canonical self-read is `GET /api/v1/actors/me`. Actor admission does not copy +issuer email or display name. Human-owned profile metadata is updated through +`PATCH /api/v1/actors/me`; verified issuer claims do not grant product roles. Human first access may create a canonical human profile and identity link. Unknown service subjects, agents, and Spaces are denied without implicit @@ -1156,26 +1156,33 @@ committed custody chain, then returns the original response without new PREP or allowed evidence. Later grant revocation denies new or changed creation requests but does not rewrite an already committed idempotent response. -Guide create, guide update, and source-snapshot metadata create require an -active human with an effective system-scoped or exact-project Project Manager -grant carrying `project.guide.manage`. Each route requires a UUID -`Idempotency-Key` before actor first-access provisioning and consumes one opaque, -transaction-bound PREP handle after locking the exact project, draft guide, and -current source lineage. Guide create produces only a draft guide. Snapshot -creation separately records the sanitized source manifest and may commit one -setup-run queue intent; broker dispatch happens only after commit and never -carries the prepared handle. +Guide creation and draft metadata updates require an active human with an +effective system-scoped or exact-project Project Manager grant carrying +`project.guide.manage`. Each public mutation requires a UUID `Idempotency-Key` +before actor first-access provisioning. Guide creation consumes distinct, +transaction-bound guide-create and internal source-consent PREP handles after +locking the project. It atomically commits the draft guide, complete document +set, paired same-key replay records and one `awaiting_documents` setup. +There is no separate public source-snapshot creation operation. + +The create response supplies document IDs. The manager uploads each original +through `POST /api/v1/projects/{project_id}/guides/{guide_id}/documents/{document_id}/content`. +Exact membership and current ingest authority are checked before body reads. +Only committed bytes for every declared document can trigger automatic setup. +Broker dispatch happens after commit and never carries a prepared handle. +Guide-create replay reauthorizes both original actions against current authority +and returns the original document IDs and initial setup response. Guide create/update no longer accept embedded review, revision, retired payout/economic, or contribution-record configuration fields. Guide create -requires task examples stored as immutable PostgreSQL JSON with the guide -metadata. AUTH receives the request digest, example hash and count; the guide +requires the complete document declarations and task examples stored as +immutable PostgreSQL metadata. AUTH receives the request digest, example hash and count; the guide and exact replay response are committed together. Document/upload snapshots bind that commitment and receive original PDF/DOCX/PPTX files through ART. Inline Markdown and URL/repository ingestion are unavailable. Only bounded metadata such as `change_summary` remains editable while the guide is draft. -Exact committed retries return the recorded response without another -mutation, setup run, or dispatch. Changed, concurrent-pending, cross-project, +For guide creation and document upload, exact committed retries return the +recorded response without another mutation, setup run, or dispatch. Changed, concurrent-pending, cross-project, stale-lineage, revoked, wrong-action, wrong-resource, or wrong-transaction use fails closed with no product write. @@ -1218,7 +1225,7 @@ execution task, calls no provider, and does not make the hidden POL workflow liv | `project.create` (active) | `project.create` | `WS-AUTH-001-12C` | | `project.guide.create` (active) | `project.guide.manage` | `WS-AUTH-001-12D` | | `project.guide.update` (active) | `project.guide.manage` | `WS-AUTH-001-12D` | -| `project.guide_source_snapshot.create` (active) | `project.guide.manage` | `WS-AUTH-001-12D` | +| `project.guide_source_snapshot.create` (active internal paired consent) | `project.guide.manage` | `WS-AUTH-001-12D` | | `project.review_policy.update` (active) | `project.review_policy.manage` | `WS-XINT-003-02B` | | `project.revision_policy.update` (active) | `project.review_policy.manage` | `WS-XINT-003-02B` | | `project.guide_sufficiency_report.create` (active) | `project.guide.manage` | `WS-AUTH-001-12E` | diff --git a/docs/spec_chunk_3_project_guide_foundation.md b/docs/spec_chunk_3_project_guide_foundation.md index 6625912ab..b1acf7c93 100644 --- a/docs/spec_chunk_3_project_guide_foundation.md +++ b/docs/spec_chunk_3_project_guide_foundation.md @@ -140,7 +140,7 @@ Adds protected v1 routes: - `GET /api/v1/projects/{project_id}` - `POST /api/v1/projects/{project_id}/guides` - `PATCH /api/v1/projects/{project_id}/guides/{guide_id}` -- `POST /api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots` +- `POST /api/v1/projects/{project_id}/guides/{guide_id}/documents/{document_id}/content` - `POST /api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports` - `POST /api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports/{report_id}/acknowledge-warnings` - `POST /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies` @@ -150,6 +150,16 @@ Adds protected v1 routes: These routes require an actor role allowed to manage project setup. +Guide creation requires at least one task example and a complete nonempty +`documents` list of labels and supported media types. Its response includes each +`document_id` and the initial `awaiting_documents` setup. Upload each original as +a raw binary body with the declared `Content-Type` and a UUID `Idempotency-Key`. +Workstream computes the digest and size, checks format and bounds, and stores +the bytes through ArtifactStore. The document-set identity and source consent +are internal custody; clients do not create or select a source snapshot. +Exact create/upload replay rechecks current authority and returns the original +result. A changed document set requires a new guide. + Committed original-document readiness dispatches the sole unified project-guide compilation through Celery. Source metadata without committed original uploads remains pending; ART readiness resumes the same generation. Guide creation alone does not invoke diff --git a/docs/template_project_guide.md b/docs/template_project_guide.md index a1681dd32..bf957c269 100644 --- a/docs/template_project_guide.md +++ b/docs/template_project_guide.md @@ -14,7 +14,8 @@ Describe what this project produces and why it matters. ## Task Examples Supplied With This Guide -Provide at least one nonblank task example in the guide-create request's +Declare the complete document list when creating the guide, and provide at +least one nonblank task example in the request's `task_examples` list. A starting idea or short description is enough; title and labels are optional. Examples need not repeat this guide's deliverables or acceptance criteria. Workstream stores the list with guide metadata in PostgreSQL @@ -29,9 +30,23 @@ each. The full canonical UTF-8 JSON list must fit within 128 KiB. Examples are immutable for that guide version; a correction uses a new guide version. ```json -{"task_examples": [{"content": "Repair intermittent memory faults in the claims processing service."}]} +{ + "version": "initial", + "task_examples": [ + {"content": "Repair intermittent memory faults in the claims processing service."} + ], + "documents": [ + {"label": "project-guide.pdf", "media_type": "application/pdf"} + ] +} ``` +The response returns each document ID and the waiting setup ID. Upload each +original as a binary body to its project/guide/document content endpoint with +the declared `Content-Type` and a UUID `Idempotency-Key`. Setup starts when every +declared original has committed bytes. There is no separate source-snapshot +creation step. + ## Business Terms Summary Describe compensation expectations in plain language when useful for project