diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/AUTH_HANDOFF.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/AUTH_HANDOFF.md index b6283a64e..30cbb27e1 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/AUTH_HANDOFF.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/AUTH_HANDOFF.md @@ -147,7 +147,8 @@ evidence. No human grant supplies fixed service authority. 04B2 defines the pre-submit materializer facts as task, assignment, project, effective submission-artifact-policy ID, pre-submit checker-policy ID, process-local prepared generation, effective-plan hash, catalogue-manifest hash, -archive SHA-256/byte count, and semantic-manifest hash. The fixed +archive SHA-256/byte count, semantic-manifest hash, and the server-selected +ArtifactStore storage scheme evaluated by the locked policy. The fixed `workstream.artifact.materializer` capability must be consumed before any prepared-byte read, ZIP open, workspace reservation, or checker result. ART ships only a deny-by-default adapter while the action is planned; XINT-06A owns diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md index e1a87cd4f..3b5d38426 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md @@ -31,8 +31,8 @@ they cross multiple L1 boundaries. | `WS-ART-001-04A3` | Add canonical semantic manifest, executable normalization, and unchanged-work gate. | L1 | Merged PR #268 | | `WS-ART-001-04A4` | Former early removal of the legacy independently invocable caller-owned submission-precheck route and contract. | L1 | Superseded by PLAN5; complete removal belongs to 05B | | `WS-ART-001-04B1` | Add the single versioned checker catalogue and compile one effective execution plan from platform defaults plus locked project policy. | L1 | Merged PR #276 | -| `WS-ART-001-04B2` | Materialize the sealed manifest tree once and execute the mandatory platform/default catalogue phases. | L1 | Active implementation | -| `WS-ART-001-04B3` | Execute locked project-policy rules through the same plan and persist one bounded immutable evidence set. | L1 | Proposed after 04B2 | +| `WS-ART-001-04B2` | Materialize the sealed manifest tree once and execute the mandatory platform/default catalogue phases. | L1 | Merged PR #282 | +| `WS-ART-001-04B3` | Execute locked project-policy rules through the same plan and persist one bounded immutable evidence set. | L1 | Active implementation | | `WS-ART-001-04C1` | Reauthorize and atomically persist capacity plus durable put intent, then write the checked ZIP once. | L1 | Proposed after XINT-06A | | `WS-ART-001-04C2` | Reuse verification/recovery to publish one capacity-charged ready admission and compose the hidden continuous endpoint. | L1 | Proposed after 04C1 | | `WS-ART-001-05A` | Atomically consume ready admission into one immutable Submission and binding under fresh human/service authority. | L1 | Proposed after XINT-05A | diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/REVIEW_LOG.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/REVIEW_LOG.md index b1ae31df3..2cc401342 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/REVIEW_LOG.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/REVIEW_LOG.md @@ -346,3 +346,19 @@ - The correction supersedes 04A4, makes 04B1 the next implementation chunk, and assigns the complete standalone/public/internal/caller-package clean cut to the admission-backed 05B Submission cutover. + +## WS-ART-001-04B3 + +- Initial review found missing migration/persistence reachability, incomplete + composite lineage, stale guide/source comparison, forged result-envelope + acceptance, replay capability duplication, and open result membership. +- The implementation now uses the real locked-context loader, database-enforced + actor/identity/assignment/task/project/policy lineage, immutable normalized + evidence, exact member-verifying replay, and one non-replayable process-local + continuation capability. +- A real isolated-PostgreSQL workflow proves passing, replay, blocked evidence, + bounded audit, scratch-safe cleanup, aggregate immutability, and absence of + artifact/admission/Submission/checker/review-queue side effects. +- Architecture, security, QA, product/ops, senior engineering, CI integrity, + docs, reuse/dedup, and test-delta final reviews pass. Full crossed-state + durable-consumption proof remains explicitly owned by ART-04C. diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md index 82aa49562..a5fbd2034 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md @@ -125,13 +125,16 @@ legacy route/public-service/internal-guard/caller-package removal to the 05B admission-backed Submission cutover. `WS-ART-001-04B1` merged through PR #276 and supplies the immutable typed -catalogue plus lineage-bound effective plan. `WS-ART-001-04B2` is implemented -and internally reviewed on its bounded branch; hosted PR gates and human merge -remain pending. It owns only fixed-service authorization before byte access, +catalogue plus lineage-bound effective plan. `WS-ART-001-04B2` merged through +PR #282. It owns only fixed-service authorization before byte access, quota-reserved callback-scoped sealed ZIP projection, platform/default phase execution, bounded non-durable results, and cleanup. It performs no project-policy execution, durable evidence/admission/Submission write, route -exposure, provider I/O, or AUTH activation. +exposure, provider I/O, or AUTH activation. `WS-ART-001-04B3` implementation +and internal L1 review are complete on its bounded branch. It executes the +project-policy continuation through that same plan and sealed tree and persists +one immutable platform-plus-project evidence set. Hosted PR gates, CodeRabbit, +human review, and merge remain pending. ## Gate diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B3-effective-pre-submit-evidence.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B3-effective-pre-submit-evidence.md index bc63a5a91..61b7d5746 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B3-effective-pre-submit-evidence.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B3-effective-pre-submit-evidence.md @@ -1,6 +1,6 @@ # Chunk Contract: WS-ART-001-04B3 - Effective Pre-Submit Evidence -Initiative: `WS-ART-001` | Risk: L1 | Status: Proposed after 04B2 +Initiative: `WS-ART-001` | Risk: L1 | Status: Active after merged PR #282 Artifact contract phase: `upload_admission` @@ -13,27 +13,63 @@ Submission, or separate contributor route. ## Allowed Files -- locked task/guide/effective-policy/checker context assembly; -- constrained project-rule execution through the central catalogue; -- pre-submit attempt/result/evidence control-plane models and one migration; -- bounded same-request contributor response projection and audit metadata; -- focused tests, docs, evidence, and CI gate maintenance. +- `backend/app/modules/checkers/pre_submit_execution.py` and new narrowly named + pre-submit result/primitive modules under `backend/app/modules/checkers/`; +- the narrow `backend/app/modules/checkers/compiler.py` projection that binds a + locked required-artifact/evidence key to its server-approved canonical ZIP + path; it may not change the policy schema, catalogue, or compilation authority; +- new pre-submit evidence models/repository under + `backend/app/modules/artifacts/`, plus their schema exports; +- the narrow actor/task model constraints required to make evidence + actor-identity-assignment-task-project lineage database-enforced; +- one Alembic migration for the closed pre-submit evidence schema; +- the smallest task-context assembler needed to lock/reload task, assignment, + predecessor, guide and policy lineage before persistence; +- `backend/tests/test_effective_pre_submit_execution.py`, + `backend/tests/test_default_pre_submit_execution.py`, migration tests, semantic + lane ownership, docs, review evidence, and exact CI-gate assertions. ## Not Allowed - project executable code, arbitrary shell/network access, or agent judgment; - a second project checker API/registry or caller-selected checker names; +- `pre_submit_static_feedback`, `CheckerRegistry`, `SubmissionCreate`, legacy + package URI/evidence-item inputs, or any other caller-owned precheck path; +- reuse of post-submit `CheckerRun`/`CheckerResult`, creation of a fake or early + Submission, or changes to the post-submit checker repository lifecycle; - provider I/O, verified admission, Submission, Review, contribution, payment, reputation, post-submit routing, or AUTH activation/grant changes; - filenames, scratch/provider references, credentials, raw checker output, or unbounded details in durable evidence. +## Persistence And Orchestration Lock + +- `PreSubmitEvidenceSet` owns the immutable attempt context; normalized + `PreSubmitEvidenceResult` rows own the ordered result members. Required + provenance is held in typed columns, never generic JSON metadata. +- One deterministic operation identity binds actor, task, project, assignment, + predecessor selector, prepared generation, archive digest/size, manifest ID + and hash, effective-plan hash, catalogue manifest hash, and locked guide, + artifact-policy, and checker-policy hashes. +- A database uniqueness constraint permits exactly one evidence set for that + operation identity. Exact replay returns the same set; any reused operation + identity with different bound facts fails closed as a conflict. +- The sealed-tree callback executes both phase families and returns one bounded + canonical result DTO. A separate transaction-bound orchestration/repository + boundary persists it only after locked context revalidation; scratch and + materialization services never perform evidence writes. +- The durable evidence set is audit/provenance. The successful immediate-use + capability remains process-local, single-use, and generation/predecessor + bound for 04C; an evidence-set ID alone is never a consumption capability. + ## Acceptance Criteria - one ordered result contains both platform/default and locked project entries, each with stable catalogue definition ID/version, source, status, severity, bounded code/message, and policy trace; +- 04B2 results and project-policy results are normalized into that one envelope + before persistence; no parallel default/project result contract survives; - the canonical typed result envelope nests identity under `definition` (`dispatch_authority`, authority-neutral definition ID/version, public name, source) and trace @@ -48,9 +84,12 @@ Submission, or separate contributor route. effective plan identity; - project rules consume server-derived manifest/workspace facts and may require project-specific files such as `task.toml` without making them platform defaults; +- locked policy keys resolve through one closed server-owned key-to-canonical-path + projection; unknown, duplicate or unmappable keys are retryable policy/setup + failures, and contributor labels or evidence tokens cannot satisfy them; - project policy can add/narrow but cannot disable, reorder, downgrade, or raise platform limits; -- blocking findings create no durable artifact, admission, Submission, review, +- blocking pre-submit checker results create no durable artifact, admission, Submission, review, contribution, compensation, reputation, or provider charge; - infrastructure/authorization failure is retryable platform state, never contributor blame or a product review decision; @@ -63,6 +102,10 @@ Submission, or separate contributor route. - no ID-addressed evidence-read route or independently invocable precheck route is introduced; the eventual 04C2 endpoint returns only bounded same-request results; +- the only audit projection is `pre_submission_check_failed` with bounded + attempt/result identifiers, stable codes, counts and categories; it excludes + filenames, paths, scratch/provider references, credentials, raw output, + evidence content, free-form checker messages and review-finding vocabulary; - focused subsystem coverage is at least 90 percent and repository coverage remains at least 78 percent. @@ -70,7 +113,7 @@ Submission, or separate contributor route. ```bash docker compose up -d --wait postgres redis -(cd backend && WORKSTREAM_TEST_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/pytest tests/test_alembic.py tests/test_effective_pre_submit_execution.py tests/test_submission_precheck_scratch.py -q) +(cd backend && WORKSTREAM_TEST_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/workstream_test .venv/bin/pytest tests/test_alembic.py tests/test_effective_pre_submit_execution.py tests/test_default_pre_submit_execution.py -q) (metadata_dir="$(mktemp -d)" && trap 'rm -rf "$metadata_dir"' EXIT && (cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL=postgresql+asyncpg://workstream:workstream@localhost:5433/postgres .venv/bin/python scripts/run_isolated_tests.py --metadata-json "$metadata_dir/result.json" --timeout-seconds 12600 -- .venv/bin/python -m pytest -q --ignore=tests/test_isolated_database_runner.py --cov=app --cov-report=term-missing --cov-fail-under=78)) (cd backend && .venv/bin/coverage report --include='app/modules/artifacts/*,app/modules/checkers/*,app/modules/tasks/*' --precision=2 --fail-under=90) (cd backend && .venv/bin/ruff check app tests) diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B3-external-review-response.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B3-external-review-response.md new file mode 100644 index 000000000..56b18fc50 --- /dev/null +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B3-external-review-response.md @@ -0,0 +1,60 @@ +# WS-ART-001-04B3 External Review Response + +## Comments addressed + +- The evidence-set database now binds `guide_id`, `project_id`, and + `guide_version` to one canonical `ProjectGuide` row through a composite + foreign key and matching unique target. +- Evidence orchestration now rejects an already-open session transaction before + materialization, and captures the prepared generation before awaiting scratch + work. +- Project evidence keys must project to a canonical relative path, so `.` and + `..` cannot collapse into ambiguous evidence identities. +- The materializer and execution validator share one closed storage-scheme + constant. +- Exact durable-evidence replay is documented as returning no new pass + capability; later submission attempts must re-prepare the bundle. +- Migration `0058` now enforces result-status/failure-code shape and rejects a + caller-supplied evidence-set creation timestamp outside the creating + transaction. +- Tests cover noncanonical evidence keys, a mismatched prepared generation + before capability consumption, and the transaction-free orchestration + precondition. +- Evidence-set delete and truncate tests now avoid child-FK interference and + assert the exact immutable-trigger error. +- The hosted task-lifecycle failure exposed one stale test mutation against the + retired `evidence_keys` config field; the test now mutates the canonical + `evidence_paths` field and proves required-evidence coverage fails closed. +- Policy primitive dispatch now has an explicit fail-closed default, result + metadata types match their integer-only validator, result schema comes from + the exact plan entry, and compiler/executor path checks share one helper. +- The persistence transaction explicitly starts at PostgreSQL `READ COMMITTED` + before locked-context reads, so a conflict replay can observe the committed + winning row. + +## Comments deferred + +None. CodeRabbit's suggested `task.locked_guide_id` filter was rejected because +`WorkstreamTask` intentionally has no such column. The task locks the unique +project/guide-version lineage; the new composite evidence foreign key binds the +resolved guide ID to that same project and version at persistence. + +## Human decisions needed + +The ART changes require no product decision. The hosted protected-mutation +workflow failure is a separate CI-reliability defect already repaired by PR +`#289`; that PR still requires repository-owner approval before merge. + +## Verification + +- focused Ruff validation for every changed Python file; +- focused effective/default pre-submit unit tests; +- isolated PostgreSQL evidence workflow and `0058` migration round trip; +- `git diff --check`. + +## Remaining risks + +PR `#291` cannot obtain a green protected-mutation result while the retired +workflow remains on `main`: its evaluator cannot map model/import-only changes +and its eight-claim ceiling is lower than this chunk's eligible target count. +No ART-local claim can safely bypass that failure. diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B3-internal-review-evidence.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B3-internal-review-evidence.md new file mode 100644 index 000000000..33de4c46d --- /dev/null +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B3-internal-review-evidence.md @@ -0,0 +1,82 @@ +# WS-ART-001-04B3 Internal Review Evidence + +## Scope + +Hidden effective pre-submission execution and immutable evidence custody only. +Provider I/O, admission, Submission creation, route exposure, AUTH activation, +review, contribution, compensation, reputation, and legacy removal remain out +of scope. + +## Deterministic evidence + +- Focused effective-execution unit suite: 14 tests passed. +- Real isolated-PostgreSQL passing, replay, blocked, immutability, and + no-side-effect workflow: passed. +- Alembic `0058_pre_submit_evidence` empty downgrade/upgrade round trip: passed. +- Focused non-database ART/CI tests: 67 passed; the database test was run + separately through the canonical isolated runner. +- Ruff passed for backend application, tests, and scripts. +- Stale artifact contracts, lightweight agent gates, Markdown links, and diff + integrity passed. +- The local combined Alembic/effective/default suite exceeded its deliberately + short 900-second diagnostic timeout after nine passing tests; hosted sharded + Backend Gates remain the authoritative full-suite and coverage proof. + +## Reviewer results + +- Architecture: PASS; exact actor/identity/assignment/task/project and locked + policy lineage is database-enforced. +- Security/auth: PASS after exact enum and boolean result-envelope validation. +- QA: PASS after real locked-context, scratch-safe passing/replay/blocked, and + immutable aggregate proofs. +- Product/ops: PASS after replay was prevented from minting a second pass + capability. +- Senior engineering: PASS after closing result membership and revalidating + every ordered result on replay. +- CI integrity: PASS WITH LOW RISK; no workflow or coverage weakening. +- Docs: PASS after canonical architecture, glossary, and artifact-spec updates. +- Reuse/dedup: PASS WITH LOW RISKS; no second checker, scratch, provider, or + persistence path was introduced. +- Test delta: PASS; no removed, skipped, xfailed, or weakened test. + +## Resolved findings + +- Added the missing Alembic schema and exact composite lineage constraints. +- Wired evidence persistence into the hidden prepared-bundle workflow. +- Revalidated exact guide version, source snapshot, and policy lineage. +- Rejected forged result status, failure-code, and eligibility values. +- Made evidence membership immutable and replay member-verifying. +- Prevented replay from issuing another process-local continuation capability. +- Proved blocked evidence produces only bounded audit/evidence effects. + +## External-review repair re-review + +- Architecture: PASS WITH LOW RISKS; the composite guide lineage, transaction + ownership, and shared checker semantics remain inside 04B3. +- Security: PASS; the new database guards fail closed. +- QA: PASS after the isolated PostgreSQL evidence workflow and migration round + trip passed together (`2 passed`). +- Product/ops: PASS; replay and blocked outcomes remain outside review, + contribution, compensation, and reputation effects. +- Senior engineering: PASS WITH LOW RISKS. +- CI integrity: PASS; no workflow, coverage, or mutation-policy bypass was + added to ART. +- Docs: PASS after replay/capability and external-response corrections. +- Reuse/dedup: PASS WITH LOW RISKS; the remaining product-code storage-scheme + literal was removed after review. +- Test delta: PASS after adding behavioral proof that a forged creation + timestamp is rejected. +- Final focused non-database suite: `37 passed, 1 deselected`; the deselected + PostgreSQL workflow passed through the canonical isolated runner. +- CodeRabbit's final guard-test nitpick was verified with the isolated + PostgreSQL workflow (`1 passed`): evidence-set delete and truncate now assert + the exact immutability-trigger error without child-FK interference. +- The hosted task-lifecycle failure was reproduced as a stale test field, not a + runtime or segmentation failure. The corrected canonical-field regression + and focused suite pass (`38 passed, 1 deselected`). + +## Remaining external gates + +GitHub Backend Gates, CodeRabbit, and human review remain external checks. Human +merge ownership remains with the repository owner; this evidence does not +authorize merge. diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B3-pr-trust-bundle.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B3-pr-trust-bundle.md new file mode 100644 index 000000000..cee65c64a --- /dev/null +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B3-pr-trust-bundle.md @@ -0,0 +1,71 @@ +# WS-ART-001-04B3 PR Trust Bundle + +## Chunk + +`WS-ART-001-04B3` — effective pre-submit execution evidence. + +## Goal + +Execute the exact task-locked project-policy entries through the same sealed +04B1/04B2 plan and persist one bounded immutable evidence set for the complete +platform-plus-project result. + +## What changed + +- Extended the hidden sealed-tree processor to execute locked project-policy + primitives through the existing catalogue and effective plan. +- Added canonical typed platform-plus-project result and policy-trace custody. +- Added a transaction-owned locked-context reload and evidence orchestration + service. +- Added normalized immutable evidence-set and ordered result persistence. +- Added exact replay, result-membership closure, member verification, bounded + failure audit, and a non-replayable process-local pass capability. +- Added Alembic `0058_pre_submit_evidence` with composite lineage constraints. +- Hardened guide identity, result shape, creation-window, canonical evidence-key, + and transaction-ownership constraints after external review. +- Updated semantic test lanes and canonical architecture documentation. + +## Scope control + +No public route, provider I/O, ArtifactContent, admission, Submission, review, +contribution, compensation, reputation, AUTH activation, generic download, or +legacy removal is introduced. + +## Acceptance proof + +Tests prove real locked actor/identity/task/assignment/guide/policy context, +passing persistence, exact replay, blocked persistence, bounded path-free audit, +single-use capability behavior, immutable aggregate membership, forged-envelope +rejection, scratch cleanup, and unchanged artifact/admission/Submission/checker/ +review-queue side-effect tables. + +## CI integrity + +No workflow or coverage floor was weakened. The new effective-execution module +is owned by the existing task-lifecycle semantic lane. Repository coverage stays +at 78 percent and the existing 90 percent subsystem gates remain intact. + +## Reviewer results + +Architecture, security, QA, product/ops, senior engineering, docs, reuse, and +test-delta passed. CI integrity passed with only the expected requirement that +the committed branch be evaluated by hosted checks. + +## Remaining risks and follow-up + +The returned pass capability is intentionally process-local and cannot be +recreated by replay. ART-04C must consume it while freshly revalidating the +complete crossed-state matrix before any durable put, provider write, admission, +or Submission effect. AUTH activation and legacy cutover remain later ordered +chunks. + +## Human review focus + +- Confirm there is one effective plan, execution envelope, and evidence chain. +- Confirm exact replay cannot grow evidence or mint another pass capability. +- Confirm blocking results create evidence/audit only and no lifecycle effect. +- Confirm 04C, AUTH activation, and provider/admission work remain out of scope. + +## Human merge ownership + +Only the repository owner may approve and merge this PR. diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-06A-pre-submit-materialization-activation.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-06A-pre-submit-materialization-activation.md index c1b46f956..8e1a5667a 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-06A-pre-submit-materialization-activation.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/chunks/WS-XINT-002-06A-pre-submit-materialization-activation.md @@ -23,7 +23,8 @@ bindings, human checker authority, generic artifact reads, or new ActionIds. - only the fixed pre-submit materializer identity may prepare and consume it; - authority binds the process-local prepared-bundle/scratch generation, task/project/guide/locked policy, archive/manifest, checker definition, - request, session, and transaction facts; no durable admission exists yet; + server-selected ArtifactStore storage scheme, request, session, and + transaction facts; no durable admission exists yet; - denial/replay/stale/cross-resource cases fail before scratch exposure; - prepared handles never enter Celery payloads. diff --git a/backend/alembic/versions/0058_pre_submit_evidence.py b/backend/alembic/versions/0058_pre_submit_evidence.py new file mode 100644 index 000000000..21bac57f3 --- /dev/null +++ b/backend/alembic/versions/0058_pre_submit_evidence.py @@ -0,0 +1,398 @@ +"""install immutable pre-submit execution evidence + +Revision ID: 0058_pre_submit_evidence +Revises: 0057_submission_policy_authority +Create Date: 2026-08-05 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0058_pre_submit_evidence" +down_revision = "0057_submission_policy_authority" +branch_labels = depends_on = None + +_SHA256 = r"^sha256:[0-9a-f]{64}$" + + +def _immutable_guard(table: str) -> None: + op.execute( + f""" + create function guard_{table}_immutable() returns trigger language plpgsql as $$ + begin + raise exception '{table} rows are immutable' using errcode='55000'; + end; + $$ + """ + ) + op.execute( + f"create trigger {table}_immutable before update or delete on {table} " + f"for each row execute function guard_{table}_immutable()" + ) + op.execute( + f"create trigger {table}_no_truncate before truncate on {table} " + f"for each statement execute function guard_{table}_immutable()" + ) + + +def upgrade() -> None: + op.create_unique_constraint( + "uq_actor_identity_links_id_profile", + "actor_identity_links", + ["id", "actor_profile_id"], + ) + op.create_unique_constraint( + "uq_workstream_tasks_id_project", "workstream_tasks", ["id", "project_id"] + ) + op.create_unique_constraint( + "uq_task_assignments_id_task_contributor", + "task_assignments", + ["id", "task_id", "contributor_id"], + ) + op.create_unique_constraint( + "uq_project_guides_id_project_version", + "project_guides", + ["id", "project_id", "version"], + ) + op.create_table( + "pre_submit_evidence_sets", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("operation_identity", sa.String(71), nullable=False), + sa.Column("actor_profile_id", sa.String(36), nullable=False), + sa.Column("identity_link_id", sa.String(36), nullable=False), + sa.Column("project_id", sa.String(36), nullable=False), + sa.Column("task_id", sa.String(36), nullable=False), + sa.Column("assignment_id", sa.String(36), nullable=False), + sa.Column("predecessor_submission_id", sa.String(36)), + sa.Column("predecessor_submission_version", sa.Integer()), + sa.Column("prepared_generation_id", sa.String(36), nullable=False), + sa.Column("archive_sha256", sa.String(71), nullable=False), + sa.Column("archive_byte_count", sa.BigInteger(), nullable=False), + sa.Column("semantic_manifest_id", sa.String(36), nullable=False), + sa.Column("semantic_manifest_sha256", sa.String(71), nullable=False), + sa.Column("guide_id", sa.String(36), nullable=False), + sa.Column("guide_version", sa.String(50), nullable=False), + sa.Column("source_snapshot_id", sa.String(36), nullable=False), + sa.Column("source_snapshot_sha256", sa.String(71), nullable=False), + sa.Column("locked_guide_sha256", sa.String(71), nullable=False), + sa.Column("effective_policy_id", sa.String(36), nullable=False), + sa.Column("locked_artifact_policy_sha256", sa.String(71), nullable=False), + sa.Column("pre_submit_policy_id", sa.String(36), nullable=False), + sa.Column("locked_checker_policy_sha256", sa.String(71), nullable=False), + sa.Column("effective_plan_sha256", sa.String(71), nullable=False), + sa.Column("catalogue_id", sa.String(160), nullable=False), + sa.Column("catalogue_version", sa.String(40), nullable=False), + sa.Column("catalogue_manifest_sha256", sa.String(71), nullable=False), + sa.Column("storage_scheme", sa.String(16), nullable=False), + sa.Column("terminal_status", sa.String(16), nullable=False), + sa.Column("eligible", sa.Boolean(), nullable=False), + sa.Column("result_count", sa.Integer(), nullable=False), + sa.Column("result_manifest_sha256", sa.String(71), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + sa.UniqueConstraint("operation_identity", name="uq_pre_submit_evidence_operation"), + sa.CheckConstraint( + f"operation_identity ~ '{_SHA256}'", name="ck_pre_submit_evidence_operation_sha256" + ), + sa.CheckConstraint( + f"archive_sha256 ~ '{_SHA256}'", name="ck_pre_submit_evidence_archive_sha256" + ), + sa.CheckConstraint( + f"semantic_manifest_sha256 ~ '{_SHA256}'", + name="ck_pre_submit_evidence_manifest_sha256", + ), + sa.CheckConstraint( + f"effective_plan_sha256 ~ '{_SHA256}'", name="ck_pre_submit_evidence_plan_sha256" + ), + sa.CheckConstraint( + f"catalogue_manifest_sha256 ~ '{_SHA256}'", + name="ck_pre_submit_evidence_catalogue_sha256", + ), + sa.CheckConstraint( + f"locked_guide_sha256 ~ '{_SHA256}'", name="ck_pre_submit_evidence_guide_sha256" + ), + sa.CheckConstraint( + f"source_snapshot_sha256 ~ '{_SHA256}'", + name="ck_pre_submit_evidence_source_snapshot_sha256", + ), + sa.CheckConstraint( + f"locked_artifact_policy_sha256 ~ '{_SHA256}'", + name="ck_pre_submit_evidence_artifact_policy_sha256", + ), + sa.CheckConstraint( + f"locked_checker_policy_sha256 ~ '{_SHA256}'", + name="ck_pre_submit_evidence_checker_policy_sha256", + ), + sa.CheckConstraint( + f"result_manifest_sha256 ~ '{_SHA256}'", + name="ck_pre_submit_evidence_result_manifest_sha256", + ), + sa.CheckConstraint("archive_byte_count >= 0", name="ck_pre_submit_evidence_archive_size"), + sa.CheckConstraint("result_count > 0", name="ck_pre_submit_evidence_result_count"), + sa.CheckConstraint( + "(predecessor_submission_id is null and predecessor_submission_version is null) " + "or (predecessor_submission_id is not null and " + "predecessor_submission_version is not null)", + name="ck_pre_submit_evidence_predecessor_shape", + ), + sa.CheckConstraint( + "storage_scheme in ('local','s3')", name="ck_pre_submit_evidence_storage_scheme" + ), + sa.CheckConstraint( + "terminal_status in ('passed','blocked')", + name="ck_pre_submit_evidence_terminal_status", + ), + sa.CheckConstraint( + "(terminal_status='passed' and eligible) or " + "(terminal_status='blocked' and not eligible)", + name="ck_pre_submit_evidence_status_eligibility", + ), + sa.ForeignKeyConstraint(["actor_profile_id"], ["actor_profiles.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint( + ["identity_link_id"], ["actor_identity_links.id"], ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["task_id"], ["workstream_tasks.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["assignment_id"], ["task_assignments.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint( + ["predecessor_submission_id"], ["submissions.id"], ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint(["guide_id"], ["project_guides.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint( + ["source_snapshot_id"], ["guide_source_snapshots.id"], ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["effective_policy_id"], + ["effective_project_submission_artifact_policies.id"], + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["pre_submit_policy_id"], ["pre_submit_checker_policies.id"], ondelete="RESTRICT" + ), + sa.ForeignKeyConstraint( + ["identity_link_id", "actor_profile_id"], + ["actor_identity_links.id", "actor_identity_links.actor_profile_id"], + name="fk_pre_submit_evidence_identity_actor", + ), + sa.ForeignKeyConstraint( + ["assignment_id", "task_id", "actor_profile_id"], + ["task_assignments.id", "task_assignments.task_id", "task_assignments.contributor_id"], + name="fk_pre_submit_evidence_assignment", + ), + sa.ForeignKeyConstraint( + ["task_id", "project_id"], + ["workstream_tasks.id", "workstream_tasks.project_id"], + name="fk_pre_submit_evidence_task_project", + ), + sa.ForeignKeyConstraint( + ["task_id", "guide_version"], + ["workstream_tasks.id", "workstream_tasks.locked_guide_version"], + name="fk_pre_submit_evidence_task_guide", + ), + sa.ForeignKeyConstraint( + ["guide_id", "project_id", "guide_version"], + ["project_guides.id", "project_guides.project_id", "project_guides.version"], + name="fk_pre_submit_evidence_guide_lineage", + ), + sa.ForeignKeyConstraint( + ["task_id", "source_snapshot_id", "source_snapshot_sha256"], + [ + "workstream_tasks.id", + "workstream_tasks.locked_guide_source_snapshot_id", + "workstream_tasks.locked_guide_source_snapshot_hash", + ], + name="fk_pre_submit_evidence_task_source_snapshot", + ), + sa.ForeignKeyConstraint( + ["predecessor_submission_id", "task_id", "predecessor_submission_version"], + ["submissions.id", "submissions.task_id", "submissions.version"], + name="fk_pre_submit_evidence_predecessor", + ), + sa.ForeignKeyConstraint( + ["task_id", "effective_policy_id", "locked_artifact_policy_sha256"], + [ + "workstream_tasks.id", + "workstream_tasks.locked_effective_project_submission_artifact_policy_id", + "workstream_tasks.locked_effective_project_submission_artifact_policy_hash", + ], + name="fk_pre_submit_evidence_task_artifact_policy", + ), + sa.ForeignKeyConstraint( + ["task_id", "pre_submit_policy_id", "locked_checker_policy_sha256"], + [ + "workstream_tasks.id", + "workstream_tasks.locked_pre_submit_checker_policy_id", + "workstream_tasks.locked_pre_submit_checker_bundle_hash", + ], + name="fk_pre_submit_evidence_task_checker_policy", + ), + ) + op.create_index( + "ix_pre_submit_evidence_sets_actor_profile_id", + "pre_submit_evidence_sets", + ["actor_profile_id"], + ) + op.create_index( + "ix_pre_submit_evidence_sets_project_id", "pre_submit_evidence_sets", ["project_id"] + ) + op.create_index("ix_pre_submit_evidence_sets_task_id", "pre_submit_evidence_sets", ["task_id"]) + op.create_table( + "pre_submit_evidence_results", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("evidence_set_id", sa.String(36), nullable=False), + sa.Column("result_order", sa.Integer(), nullable=False), + sa.Column("schema_version", sa.String(80), nullable=False), + sa.Column("dispatch_authority", sa.String(160), nullable=False), + sa.Column("definition_id", sa.String(160), nullable=False), + sa.Column("definition_version", sa.String(40), nullable=False), + sa.Column("public_name", sa.String(160), nullable=False), + sa.Column("source", sa.String(160), nullable=False), + sa.Column("phase", sa.String(40), nullable=False), + sa.Column("classification", sa.String(40), nullable=False), + sa.Column("severity", sa.String(16), nullable=False), + sa.Column("status", sa.String(40), nullable=False), + sa.Column("failure_code", sa.String(160)), + sa.Column("message_code", sa.String(160), nullable=False), + sa.Column("effective_plan_sha256", sa.String(71), nullable=False), + sa.Column("rule_instance_id", sa.String(71)), + sa.Column("locked_policy_sha256", sa.String(71), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + sa.UniqueConstraint("evidence_set_id", "result_order", name="uq_pre_submit_result_order"), + sa.UniqueConstraint( + "evidence_set_id", "definition_id", name="uq_pre_submit_result_definition" + ), + sa.CheckConstraint("result_order >= 0", name="ck_pre_submit_result_order"), + sa.CheckConstraint( + "status in ('passed','warning','advisory_disabled','dependency_not_run','failed')", + name="ck_pre_submit_result_status", + ), + sa.CheckConstraint( + "(status='failed' and failure_code is not null) or " + "(status<>'failed' and failure_code is null)", + name="result_failure_shape", + ), + sa.CheckConstraint( + "phase in ('custody','identity','materialization','default_policy','project_policy')", + name="ck_pre_submit_result_phase", + ), + sa.CheckConstraint( + "classification in ('mandatory_security','mandatory_integrity'," + "'mandatory_accountability','advisory')", + name="ck_pre_submit_result_classification", + ), + sa.CheckConstraint( + "severity in ('blocking','warning')", name="ck_pre_submit_result_severity" + ), + sa.CheckConstraint( + "(classification='advisory' and severity='warning') or " + "(classification<>'advisory' and severity='blocking')", + name="ck_pre_submit_result_classification_severity", + ), + sa.CheckConstraint( + f"effective_plan_sha256 ~ '{_SHA256}'", name="ck_pre_submit_result_plan_sha256" + ), + sa.CheckConstraint( + f"locked_policy_sha256 ~ '{_SHA256}'", name="ck_pre_submit_result_policy_sha256" + ), + sa.CheckConstraint( + "(phase='project_policy' and rule_instance_id is not null and " + f"rule_instance_id ~ '{_SHA256}') or " + "(phase<>'project_policy' and rule_instance_id is null)", + name="ck_pre_submit_result_rule_instance_shape", + ), + sa.ForeignKeyConstraint( + ["evidence_set_id"], ["pre_submit_evidence_sets.id"], ondelete="RESTRICT" + ), + ) + op.create_index( + "ix_pre_submit_evidence_results_evidence_set_id", + "pre_submit_evidence_results", + ["evidence_set_id"], + ) + op.execute( + """ + create function guard_pre_submit_evidence_set_creation() returns trigger + language plpgsql as $$ + begin + if new.created_at is distinct from transaction_timestamp() then + raise exception 'pre-submit evidence creation timestamp is invalid' + using errcode='55000'; + end if; + return new; + end; + $$ + """ + ) + op.execute( + "create trigger pre_submit_evidence_sets_creation before insert " + "on pre_submit_evidence_sets for each row execute function " + "guard_pre_submit_evidence_set_creation()" + ) + op.execute( + """ + create function guard_pre_submit_evidence_result_membership() returns trigger + language plpgsql as $$ + declare parent_created_at timestamptz; expected_count integer; current_count integer; + begin + select created_at, result_count into parent_created_at, expected_count + from pre_submit_evidence_sets where id=new.evidence_set_id for key share; + select count(*) into current_count from pre_submit_evidence_results + where evidence_set_id=new.evidence_set_id; + if parent_created_at is null + or parent_created_at <> transaction_timestamp() + or current_count >= expected_count then + raise exception 'pre-submit evidence result membership is closed' + using errcode='55000'; + end if; + return new; + end; + $$ + """ + ) + op.execute( + "create trigger pre_submit_evidence_results_membership before insert " + "on pre_submit_evidence_results for each row execute function " + "guard_pre_submit_evidence_result_membership()" + ) + _immutable_guard("pre_submit_evidence_results") + _immutable_guard("pre_submit_evidence_sets") + + +def downgrade() -> None: + bind = op.get_bind() + if bind.execute(sa.text("select count(*) from pre_submit_evidence_sets")).scalar_one(): + raise RuntimeError("cannot downgrade populated immutable pre-submit evidence") + op.execute("drop trigger pre_submit_evidence_results_membership on pre_submit_evidence_results") + op.execute("drop function guard_pre_submit_evidence_result_membership()") + op.execute("drop trigger pre_submit_evidence_sets_creation on pre_submit_evidence_sets") + op.execute("drop function guard_pre_submit_evidence_set_creation()") + for table in ("pre_submit_evidence_sets", "pre_submit_evidence_results"): + op.execute(f"drop trigger {table}_no_truncate on {table}") + op.execute(f"drop trigger {table}_immutable on {table}") + op.execute(f"drop function guard_{table}_immutable()") + op.drop_index( + "ix_pre_submit_evidence_results_evidence_set_id", + table_name="pre_submit_evidence_results", + ) + op.drop_table("pre_submit_evidence_results") + for name in ( + "ix_pre_submit_evidence_sets_task_id", + "ix_pre_submit_evidence_sets_project_id", + "ix_pre_submit_evidence_sets_actor_profile_id", + ): + op.drop_index(name, table_name="pre_submit_evidence_sets") + op.drop_table("pre_submit_evidence_sets") + op.drop_constraint( + "uq_project_guides_id_project_version", "project_guides", type_="unique" + ) + op.drop_constraint( + "uq_task_assignments_id_task_contributor", "task_assignments", type_="unique" + ) + op.drop_constraint("uq_workstream_tasks_id_project", "workstream_tasks", type_="unique") + op.drop_constraint("uq_actor_identity_links_id_profile", "actor_identity_links", type_="unique") diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 1ce8f216f..40617af02 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -20,6 +20,8 @@ ArtifactOperationReceipt, ArtifactReplica, ArtifactRecoveryAttempt, + PreSubmitEvidenceResult, + PreSubmitEvidenceSet, ) from app.modules.authorization.models import ( # noqa: F401 AdminRoleGrant, diff --git a/backend/app/interfaces/artifact_operations.py b/backend/app/interfaces/artifact_operations.py index 32a0ddd19..0b45bfea4 100644 --- a/backend/app/interfaces/artifact_operations.py +++ b/backend/app/interfaces/artifact_operations.py @@ -17,7 +17,7 @@ from app.modules.authorization.runtime import AuthorizationContext from app.modules.checkers.effective_plan import EffectivePreSubmissionExecutionPlan from app.modules.checkers.pre_submit_execution import ( - DefaultPreSubmissionExecutionResult, + PreSubmissionExecutionResult, SubmissionPacketView, ) @@ -372,7 +372,7 @@ class ArtifactMaterializationPort(Protocol): async def materialize_prepared_bundle( self, request: PreparedBundleMaterializationRequest, - ) -> DefaultPreSubmissionExecutionResult: + ) -> PreSubmissionExecutionResult: """Materialize one process-local prepared bundle generation.""" async def materialize_guide_source( diff --git a/backend/app/modules/actors/models.py b/backend/app/modules/actors/models.py index 7e37b3f78..c30d0f467 100644 --- a/backend/app/modules/actors/models.py +++ b/backend/app/modules/actors/models.py @@ -156,6 +156,9 @@ class ActorIdentityLink(Base): ), UniqueConstraint("issuer", "subject", name="external_identity"), UniqueConstraint("actor_profile_id", name="actor_profile"), + UniqueConstraint( + "id", "actor_profile_id", name="uq_actor_identity_links_id_profile" + ), Index( "ix_actor_identity_links_issuer_subject_status", "issuer", diff --git a/backend/app/modules/artifacts/models.py b/backend/app/modules/artifacts/models.py index 010a0512f..d36bc91ae 100644 --- a/backend/app/modules/artifacts/models.py +++ b/backend/app/modules/artifacts/models.py @@ -6,6 +6,7 @@ from sqlalchemy import ( BigInteger, + Boolean, CheckConstraint, DateTime, ForeignKey, @@ -48,6 +49,264 @@ class ArtifactContent(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) +class PreSubmitEvidenceSet(Base): + """Immutable execution provenance for one exact prepared submission bundle.""" + + __tablename__ = "pre_submit_evidence_sets" + __table_args__ = ( + ForeignKeyConstraint( + ["identity_link_id", "actor_profile_id"], + ["actor_identity_links.id", "actor_identity_links.actor_profile_id"], + name="fk_pre_submit_evidence_identity_actor", + ), + ForeignKeyConstraint( + ["assignment_id", "task_id", "actor_profile_id"], + ["task_assignments.id", "task_assignments.task_id", "task_assignments.contributor_id"], + name="fk_pre_submit_evidence_assignment", + ), + ForeignKeyConstraint( + ["task_id", "project_id"], + ["workstream_tasks.id", "workstream_tasks.project_id"], + name="fk_pre_submit_evidence_task_project", + ), + ForeignKeyConstraint( + ["task_id", "guide_version"], + ["workstream_tasks.id", "workstream_tasks.locked_guide_version"], + name="fk_pre_submit_evidence_task_guide", + ), + ForeignKeyConstraint( + ["guide_id", "project_id", "guide_version"], + ["project_guides.id", "project_guides.project_id", "project_guides.version"], + name="fk_pre_submit_evidence_guide_lineage", + ), + ForeignKeyConstraint( + ["task_id", "source_snapshot_id", "source_snapshot_sha256"], + [ + "workstream_tasks.id", + "workstream_tasks.locked_guide_source_snapshot_id", + "workstream_tasks.locked_guide_source_snapshot_hash", + ], + name="fk_pre_submit_evidence_task_source_snapshot", + ), + ForeignKeyConstraint( + ["predecessor_submission_id", "task_id", "predecessor_submission_version"], + ["submissions.id", "submissions.task_id", "submissions.version"], + name="fk_pre_submit_evidence_predecessor", + ), + ForeignKeyConstraint( + ["task_id", "effective_policy_id", "locked_artifact_policy_sha256"], + [ + "workstream_tasks.id", + "workstream_tasks.locked_effective_project_submission_artifact_policy_id", + "workstream_tasks.locked_effective_project_submission_artifact_policy_hash", + ], + name="fk_pre_submit_evidence_task_artifact_policy", + ), + ForeignKeyConstraint( + ["task_id", "pre_submit_policy_id", "locked_checker_policy_sha256"], + [ + "workstream_tasks.id", + "workstream_tasks.locked_pre_submit_checker_policy_id", + "workstream_tasks.locked_pre_submit_checker_bundle_hash", + ], + name="fk_pre_submit_evidence_task_checker_policy", + ), + UniqueConstraint("operation_identity", name="uq_pre_submit_evidence_operation"), + CheckConstraint( + SHA256_CHECK.format(column="operation_identity"), + name="ck_pre_submit_evidence_operation_sha256", + ), + CheckConstraint( + SHA256_CHECK.format(column="archive_sha256"), + name="ck_pre_submit_evidence_archive_sha256", + ), + CheckConstraint( + SHA256_CHECK.format(column="semantic_manifest_sha256"), + name="ck_pre_submit_evidence_manifest_sha256", + ), + CheckConstraint( + SHA256_CHECK.format(column="effective_plan_sha256"), + name="ck_pre_submit_evidence_plan_sha256", + ), + CheckConstraint( + SHA256_CHECK.format(column="catalogue_manifest_sha256"), + name="ck_pre_submit_evidence_catalogue_sha256", + ), + CheckConstraint( + SHA256_CHECK.format(column="locked_guide_sha256"), + name="ck_pre_submit_evidence_guide_sha256", + ), + CheckConstraint( + SHA256_CHECK.format(column="source_snapshot_sha256"), + name="ck_pre_submit_evidence_source_snapshot_sha256", + ), + CheckConstraint( + SHA256_CHECK.format(column="locked_artifact_policy_sha256"), + name="ck_pre_submit_evidence_artifact_policy_sha256", + ), + CheckConstraint( + SHA256_CHECK.format(column="locked_checker_policy_sha256"), + name="ck_pre_submit_evidence_checker_policy_sha256", + ), + CheckConstraint( + SHA256_CHECK.format(column="result_manifest_sha256"), + name="ck_pre_submit_evidence_result_manifest_sha256", + ), + CheckConstraint("archive_byte_count >= 0", name="ck_pre_submit_evidence_archive_size"), + CheckConstraint("result_count > 0", name="ck_pre_submit_evidence_result_count"), + CheckConstraint( + "(predecessor_submission_id is null and predecessor_submission_version is null) " + "or (predecessor_submission_id is not null and " + "predecessor_submission_version is not null)", + name="ck_pre_submit_evidence_predecessor_shape", + ), + CheckConstraint( + "storage_scheme in ('local','s3')", name="ck_pre_submit_evidence_storage_scheme" + ), + CheckConstraint( + "terminal_status in ('passed','blocked')", + name="ck_pre_submit_evidence_terminal_status", + ), + CheckConstraint( + "(terminal_status='passed' and eligible) or " + "(terminal_status='blocked' and not eligible)", + name="ck_pre_submit_evidence_status_eligibility", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + operation_identity: Mapped[str] = mapped_column(String(71), nullable=False) + actor_profile_id: Mapped[str] = mapped_column( + ForeignKey("actor_profiles.id", ondelete="RESTRICT"), nullable=False, index=True + ) + identity_link_id: Mapped[str] = mapped_column( + ForeignKey("actor_identity_links.id", ondelete="RESTRICT"), nullable=False + ) + project_id: Mapped[str] = mapped_column( + ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False, index=True + ) + task_id: Mapped[str] = mapped_column( + ForeignKey("workstream_tasks.id", ondelete="RESTRICT"), nullable=False, index=True + ) + assignment_id: Mapped[str] = mapped_column( + ForeignKey("task_assignments.id", ondelete="RESTRICT"), nullable=False + ) + predecessor_submission_id: Mapped[str | None] = mapped_column( + ForeignKey("submissions.id", ondelete="RESTRICT") + ) + predecessor_submission_version: Mapped[int | None] = mapped_column(Integer) + prepared_generation_id: Mapped[str] = mapped_column(String(36), nullable=False) + archive_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + archive_byte_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + semantic_manifest_id: Mapped[str] = mapped_column(String(36), nullable=False) + semantic_manifest_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + guide_id: Mapped[str] = mapped_column( + ForeignKey("project_guides.id", ondelete="RESTRICT"), nullable=False + ) + guide_version: Mapped[str] = mapped_column(String(50), nullable=False) + source_snapshot_id: Mapped[str] = mapped_column( + ForeignKey("guide_source_snapshots.id", ondelete="RESTRICT"), nullable=False + ) + source_snapshot_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + locked_guide_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + effective_policy_id: Mapped[str] = mapped_column( + ForeignKey("effective_project_submission_artifact_policies.id", ondelete="RESTRICT"), + nullable=False, + ) + locked_artifact_policy_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + pre_submit_policy_id: Mapped[str] = mapped_column( + ForeignKey("pre_submit_checker_policies.id", ondelete="RESTRICT"), nullable=False + ) + locked_checker_policy_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + effective_plan_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + catalogue_id: Mapped[str] = mapped_column(String(160), nullable=False) + catalogue_version: Mapped[str] = mapped_column(String(40), nullable=False) + catalogue_manifest_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + storage_scheme: Mapped[str] = mapped_column(String(16), nullable=False) + terminal_status: Mapped[str] = mapped_column(String(16), nullable=False) + eligible: Mapped[bool] = mapped_column(Boolean, nullable=False) + result_count: Mapped[int] = mapped_column(Integer, nullable=False) + result_manifest_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +class PreSubmitEvidenceResult(Base): + """One immutable ordered member of a pre-submit evidence set.""" + + __tablename__ = "pre_submit_evidence_results" + __table_args__ = ( + UniqueConstraint("evidence_set_id", "result_order", name="uq_pre_submit_result_order"), + UniqueConstraint( + "evidence_set_id", "definition_id", + name="uq_pre_submit_result_definition", + ), + CheckConstraint("result_order >= 0", name="ck_pre_submit_result_order"), + CheckConstraint( + "status in ('passed','warning','advisory_disabled','dependency_not_run','failed')", + name="ck_pre_submit_result_status", + ), + CheckConstraint( + "(status='failed' and failure_code is not null) or " + "(status<>'failed' and failure_code is null)", + name="result_failure_shape", + ), + CheckConstraint( + "phase in ('custody','identity','materialization','default_policy','project_policy')", + name="ck_pre_submit_result_phase", + ), + CheckConstraint( + "classification in ('mandatory_security','mandatory_integrity'," + "'mandatory_accountability','advisory')", + name="ck_pre_submit_result_classification", + ), + CheckConstraint( + "severity in ('blocking','warning')", + name="ck_pre_submit_result_severity", + ), + CheckConstraint( + "(classification='advisory' and severity='warning') or " + "(classification<>'advisory' and severity='blocking')", + name="ck_pre_submit_result_classification_severity", + ), + CheckConstraint( + SHA256_CHECK.format(column="effective_plan_sha256"), + name="ck_pre_submit_result_plan_sha256", + ), + CheckConstraint( + SHA256_CHECK.format(column="locked_policy_sha256"), + name="ck_pre_submit_result_policy_sha256", + ), + CheckConstraint( + "(phase='project_policy' and rule_instance_id is not null and " + "rule_instance_id ~ '^sha256:[0-9a-f]{64}$') or " + "(phase<>'project_policy' and rule_instance_id is null)", + name="ck_pre_submit_result_rule_instance_shape", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + evidence_set_id: Mapped[str] = mapped_column( + ForeignKey("pre_submit_evidence_sets.id", ondelete="RESTRICT"), nullable=False, index=True + ) + result_order: Mapped[int] = mapped_column(Integer, nullable=False) + schema_version: Mapped[str] = mapped_column(String(80), nullable=False) + dispatch_authority: Mapped[str] = mapped_column(String(160), nullable=False) + definition_id: Mapped[str] = mapped_column(String(160), nullable=False) + definition_version: Mapped[str] = mapped_column(String(40), nullable=False) + public_name: Mapped[str] = mapped_column(String(160), nullable=False) + source: Mapped[str] = mapped_column(String(160), nullable=False) + phase: Mapped[str] = mapped_column(String(40), nullable=False) + classification: Mapped[str] = mapped_column(String(40), nullable=False) + severity: Mapped[str] = mapped_column(String(16), nullable=False) + status: Mapped[str] = mapped_column(String(40), nullable=False) + failure_code: Mapped[str | None] = mapped_column(String(160)) + message_code: Mapped[str] = mapped_column(String(160), nullable=False) + effective_plan_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + rule_instance_id: Mapped[str | None] = mapped_column(String(71)) + locked_policy_sha256: Mapped[str] = mapped_column(String(71), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + class ArtifactBinding(Base): """Immutable attachment of content to one Workstream resource role.""" diff --git a/backend/app/modules/artifacts/pre_submit_evidence.py b/backend/app/modules/artifacts/pre_submit_evidence.py new file mode 100644 index 000000000..24701f7e5 --- /dev/null +++ b/backend/app/modules/artifacts/pre_submit_evidence.py @@ -0,0 +1,541 @@ +"""Immutable persistence for one exact effective pre-submit execution.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +import threading +from uuid import NAMESPACE_URL, UUID, uuid4, uuid5 + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.modules.artifacts.models import PreSubmitEvidenceResult, PreSubmitEvidenceSet +from app.modules.artifacts.sources import ArtifactCommitment +from app.modules.checkers.pre_submit_execution import ( + ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES, + PreSubmissionEntryResult, + PreSubmissionExecutionResult, + validate_pre_submission_execution_result, +) +from app.modules.checkers.effective_plan import EffectivePreSubmissionExecutionPlan + + +class PreSubmitEvidenceConflict(RuntimeError): + """Reject reuse of one operation identity with different server-owned facts.""" + + +@dataclass(frozen=True, slots=True) +class PreSubmitEvidenceContext: + """Exact locked facts revalidated by the transaction-owning caller.""" + + actor_profile_id: UUID + identity_link_id: UUID + project_id: UUID + task_id: UUID + assignment_id: UUID + predecessor_submission_id: UUID | None + predecessor_submission_version: int | None + prepared_generation_id: UUID + archive_sha256: str + archive_byte_count: int + semantic_manifest_id: UUID + semantic_manifest_sha256: str + guide_id: UUID + guide_version: str + source_snapshot_id: UUID + source_snapshot_sha256: str + locked_guide_sha256: str + effective_policy_id: UUID + locked_artifact_policy_sha256: str + pre_submit_policy_id: UUID + locked_checker_policy_sha256: str + catalogue_id: str + catalogue_version: str + catalogue_manifest_sha256: str + storage_scheme: str + + def __post_init__(self) -> None: + identifiers = ( + self.actor_profile_id, + self.identity_link_id, + self.project_id, + self.task_id, + self.assignment_id, + self.prepared_generation_id, + self.semantic_manifest_id, + self.guide_id, + self.source_snapshot_id, + self.effective_policy_id, + self.pre_submit_policy_id, + ) + if any(type(value) is not UUID for value in identifiers) or ( + self.predecessor_submission_id is not None + and type(self.predecessor_submission_id) is not UUID + ): + raise ValueError("pre-submit evidence identity is invalid") + if type(self.archive_byte_count) is not int or self.archive_byte_count < 0: + raise ValueError("pre-submit evidence archive size is invalid") + if (self.predecessor_submission_id is None) != ( + self.predecessor_submission_version is None + ) or ( + self.predecessor_submission_version is not None + and ( + type(self.predecessor_submission_version) is not int + or self.predecessor_submission_version < 1 + ) + ): + raise ValueError("pre-submit evidence predecessor lineage is invalid") + for digest in ( + self.archive_sha256, + self.semantic_manifest_sha256, + self.locked_guide_sha256, + self.source_snapshot_sha256, + self.locked_artifact_policy_sha256, + self.locked_checker_policy_sha256, + self.catalogue_manifest_sha256, + ): + ArtifactCommitment.validate_sha256(digest) + if any( + type(value) is not str or not value + for value in ( + self.guide_version, + self.catalogue_id, + self.catalogue_version, + self.storage_scheme, + ) + ): + raise ValueError("pre-submit evidence catalogue identity is invalid") + if self.storage_scheme not in ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES: + raise ValueError("pre-submit evidence storage scheme is invalid") + + def operation_identity(self, *, effective_plan_sha256: str) -> str: + """Derive the sole replay namespace from every custody and policy fact.""" + return canonical_json_hash( + { + "domain": "workstream.pre_submit_evidence_operation.v1", + **{ + key: str(value) if isinstance(value, UUID) else value + for key, value in asdict(self).items() + }, + "effective_plan_sha256": effective_plan_sha256, + } + ) + + +@dataclass(frozen=True, slots=True) +class PersistedPreSubmitEvidence: + evidence_set_id: UUID + operation_identity: str + replayed: bool + + +@dataclass(frozen=True, slots=True) +class PreSubmitEvidencePersistenceRequest: + """Process-local execution and custody facts supplied after scratch cleanup.""" + + actor_profile_id: UUID + identity_link_id: UUID + task_id: UUID + assignment_id: UUID + predecessor_submission_id: UUID | None + prepared_generation_id: UUID + archive_sha256: str + archive_byte_count: int + semantic_manifest_sha256: str + plan: EffectivePreSubmissionExecutionPlan + execution: PreSubmissionExecutionResult + + +@dataclass(frozen=True, slots=True) +class PreSubmitEvidencePersistenceResult: + """Durable evidence identity plus optional process-local pass capability.""" + + evidence: PersistedPreSubmitEvidence + pass_capability: PreSubmitPassCapability | None + failure_audit: dict[str, object] | None + + +def pre_submit_failure_audit_payload( + *, + actor_profile_id: UUID, + project_id: UUID, + task_id: UUID, + prepared_generation_id: UUID, + evidence: PersistedPreSubmitEvidence, + execution: PreSubmissionExecutionResult, + catalogue_id: str, + catalogue_version: str, +) -> dict[str, object]: + """Return the sole bounded, path-free audit projection for a blocked attempt.""" + if execution.eligible: + raise ValueError("passing pre-submit execution has no failure audit projection") + counts = {"failed": 0, "warning": 0, "not_run": 0} + categories: set[str] = set() + failure_codes: set[str] = set() + result_outcomes: list[dict[str, str]] = [] + for result in execution.entries: + result_outcomes.append( + { + "definition_id": result.definition.definition_id, + "definition_version": result.definition.definition_version, + "status": result.status.value, + "message_code": result.message_code, + } + ) + if result.status.value == "failed": + counts["failed"] += 1 + categories.add(result.classification) + if result.failure_code is not None: + failure_codes.add(result.failure_code) + elif result.status.value == "warning": + counts["warning"] += 1 + elif result.status.value == "dependency_not_run": + counts["not_run"] += 1 + return { + "event_type": "pre_submission_check_failed", + "actor_profile_id": str(actor_profile_id), + "project_id": str(project_id), + "task_id": str(task_id), + "preparation_attempt_id": str(prepared_generation_id), + "pre_submit_evidence_set_id": str(evidence.evidence_set_id), + "effective_plan_sha256": execution.plan_sha256, + "terminal_status": "blocked", + "catalogue_id": catalogue_id, + "catalogue_version": catalogue_version, + "failed_count": counts["failed"], + "warning_count": counts["warning"], + "not_run_count": counts["not_run"], + "failure_categories": sorted(categories), + "failure_codes": sorted(failure_codes), + "result_outcomes": result_outcomes, + "outcome_code": "pre_submission_checker_failed", + } + + +def semantic_manifest_identity(semantic_manifest_sha256: str) -> UUID: + """Derive one provider-neutral identity from the canonical manifest digest.""" + ArtifactCommitment.validate_sha256(semantic_manifest_sha256) + return uuid5( + NAMESPACE_URL, + f"workstream:submission-semantic-manifest:v1:{semantic_manifest_sha256}", + ) + + +class PreSubmitPassCapability: + """Single-use process-local proof for immediate 04C continuation only.""" + + __slots__ = ( + "_consumed", + "_lock", + "evidence_set_id", + "prepared_generation_id", + "predecessor_submission_id", + "effective_plan_sha256", + "archive_sha256", + "semantic_manifest_sha256", + "storage_scheme", + ) + + def __init__( + self, + *, + evidence_set_id: UUID, + prepared_generation_id: UUID, + predecessor_submission_id: UUID | None, + effective_plan_sha256: str, + archive_sha256: str, + semantic_manifest_sha256: str, + storage_scheme: str, + ) -> None: + self.evidence_set_id = evidence_set_id + self.prepared_generation_id = prepared_generation_id + self.predecessor_submission_id = predecessor_submission_id + self.effective_plan_sha256 = effective_plan_sha256 + self.archive_sha256 = archive_sha256 + self.semantic_manifest_sha256 = semantic_manifest_sha256 + self.storage_scheme = storage_scheme + self._consumed = False + self._lock = threading.Lock() + + def consume( + self, + *, + prepared_generation_id: UUID, + predecessor_submission_id: UUID | None, + effective_plan_sha256: str, + archive_sha256: str, + semantic_manifest_sha256: str, + storage_scheme: str, + ) -> UUID: + """Consume once only when the immediate continuation facts still match.""" + with self._lock: + if self._consumed or ( + prepared_generation_id != self.prepared_generation_id + or predecessor_submission_id != self.predecessor_submission_id + or effective_plan_sha256 != self.effective_plan_sha256 + or archive_sha256 != self.archive_sha256 + or semantic_manifest_sha256 != self.semantic_manifest_sha256 + or storage_scheme != self.storage_scheme + ): + raise PreSubmitEvidenceConflict("pre_submit_pass_capability_invalid") + self._consumed = True + return self.evidence_set_id + + +class _PreSubmitEvidenceRepository: + """Insert or replay one immutable evidence set in the caller transaction.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def persist( + self, + *, + context: PreSubmitEvidenceContext, + plan: EffectivePreSubmissionExecutionPlan, + execution: PreSubmissionExecutionResult, + ) -> PersistedPreSubmitEvidence: + """Persist exact results once; changed facts under the identity fail closed.""" + transaction = self._session.sync_session.get_transaction() + if ( + transaction is None + or not transaction.is_active + or self._session.in_nested_transaction() + ): + raise RuntimeError("pre-submit evidence requires one root transaction") + validate_pre_submission_execution_result(plan, execution) + operation_identity = context.operation_identity(effective_plan_sha256=execution.plan_sha256) + values = self._set_values(context, execution, operation_identity) + evidence_set_id = uuid4() + inserted_id = await self._session.scalar( + insert(PreSubmitEvidenceSet) + .values(id=str(evidence_set_id), **values) + .on_conflict_do_nothing(index_elements=["operation_identity"]) + .returning(PreSubmitEvidenceSet.id) + ) + if inserted_id is None: + existing = await self._session.scalar( + select(PreSubmitEvidenceSet).where( + PreSubmitEvidenceSet.operation_identity == operation_identity + ) + ) + if existing is None or any( + getattr(existing, key) != value for key, value in values.items() + ): + raise PreSubmitEvidenceConflict("pre_submit_evidence_operation_conflict") + persisted_results = tuple( + ( + await self._session.scalars( + select(PreSubmitEvidenceResult) + .where(PreSubmitEvidenceResult.evidence_set_id == existing.id) + .order_by(PreSubmitEvidenceResult.result_order) + ) + ).all() + ) + expected_results = tuple( + self._result_values(result_order, result) + for result_order, result in enumerate(execution.entries) + ) + if len(persisted_results) != len(expected_results) or any( + any(getattr(persisted, key) != value for key, value in expected.items()) + for persisted, expected in zip(persisted_results, expected_results, strict=True) + ): + raise PreSubmitEvidenceConflict("pre_submit_evidence_result_conflict") + return PersistedPreSubmitEvidence( + evidence_set_id=UUID(existing.id), + operation_identity=operation_identity, + replayed=True, + ) + for result_order, result in enumerate(execution.entries): + self._session.add( + PreSubmitEvidenceResult( + id=str(uuid4()), + evidence_set_id=str(evidence_set_id), + **self._result_values(result_order, result), + ) + ) + await self._session.flush() + return PersistedPreSubmitEvidence( + evidence_set_id=evidence_set_id, + operation_identity=operation_identity, + replayed=False, + ) + + @staticmethod + def _result_values(result_order: int, result: PreSubmissionEntryResult) -> dict[str, object]: + return { + "result_order": result_order, + "schema_version": result.schema_version, + "dispatch_authority": result.definition.dispatch_authority, + "definition_id": result.definition.definition_id, + "definition_version": result.definition.definition_version, + "public_name": result.definition.public_name, + "source": result.definition.source, + "phase": result.phase, + "classification": result.classification, + "severity": result.severity, + "status": result.status.value, + "failure_code": result.failure_code, + "message_code": result.message_code, + "effective_plan_sha256": result.policy_trace.effective_plan_sha256, + "rule_instance_id": result.policy_trace.rule_instance_id, + "locked_policy_sha256": result.policy_trace.locked_policy_sha256, + } + + @staticmethod + def _set_values( + context: PreSubmitEvidenceContext, + execution: PreSubmissionExecutionResult, + operation_identity: str, + ) -> dict[str, object]: + values = { + key: str(value) if isinstance(value, UUID) else value + for key, value in asdict(context).items() + } + return { + "operation_identity": operation_identity, + **values, + "effective_plan_sha256": execution.plan_sha256, + "terminal_status": "passed" if execution.eligible else "blocked", + "eligible": execution.eligible, + "result_count": len(execution.entries), + "result_manifest_sha256": canonical_json_hash( + { + "schema_version": "pre_submit_evidence_result_manifest.v1", + "entries": [ + { + "schema_version": result.schema_version, + "definition": asdict(result.definition), + "policy_trace": asdict(result.policy_trace), + "phase": result.phase, + "order": result.order, + "classification": result.classification, + "severity": result.severity, + "status": result.status.value, + "failure_code": result.failure_code, + "message_code": result.message_code, + "metadata": [list(item) for item in result.metadata], + } + for result in execution.entries + ], + } + ), + } + + +class PreSubmitEvidenceService: + """Revalidate locked state and persist evidence in one caller transaction.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + self._repository = _PreSubmitEvidenceRepository(session) + + async def persist( + self, request: PreSubmitEvidencePersistenceRequest + ) -> PreSubmitEvidencePersistenceResult: + """Persist only when the post-execution database lineage remains exact.""" + from app.modules.tasks.pre_submit_context import load_locked_pre_submit_context + + transaction = self._session.sync_session.get_transaction() + if ( + transaction is None + or not transaction.is_active + or self._session.in_nested_transaction() + ): + raise RuntimeError("pre-submit evidence requires one root transaction") + validate_pre_submission_execution_result(request.plan, request.execution) + custody = request.execution.custody + if ( + request.prepared_generation_id != custody.prepared_generation_id + or request.archive_sha256 != custody.archive_sha256 + or request.archive_byte_count != custody.archive_byte_count + or request.semantic_manifest_sha256 != custody.semantic_manifest_sha256 + ): + raise PreSubmitEvidenceConflict("pre_submit_execution_custody_changed") + locked = await load_locked_pre_submit_context( + self._session, + actor_profile_id=request.actor_profile_id, + identity_link_id=request.identity_link_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + predecessor_submission_id=request.predecessor_submission_id, + ) + lineage = request.plan.lineage + if ( + locked.project_id != lineage.project_id + or locked.guide_id != lineage.guide_id + or locked.guide_version != str(lineage.guide_version) + or locked.source_snapshot_id != lineage.source_snapshot_id + or locked.source_snapshot_sha256 != lineage.source_snapshot_hash + or locked.effective_policy_id != lineage.effective_policy_id + or locked.effective_policy_sha256 != lineage.effective_policy_hash + or locked.pre_submit_policy_id != lineage.pre_submit_policy_id + or locked.pre_submit_policy_sha256 != lineage.pre_submit_policy_bundle_hash + ): + raise PreSubmitEvidenceConflict("pre_submit_locked_context_changed") + context = PreSubmitEvidenceContext( + actor_profile_id=request.actor_profile_id, + identity_link_id=request.identity_link_id, + project_id=locked.project_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + predecessor_submission_id=request.predecessor_submission_id, + predecessor_submission_version=locked.predecessor_submission_version, + prepared_generation_id=request.prepared_generation_id, + archive_sha256=request.archive_sha256, + archive_byte_count=request.archive_byte_count, + semantic_manifest_id=semantic_manifest_identity(request.semantic_manifest_sha256), + semantic_manifest_sha256=request.semantic_manifest_sha256, + guide_id=locked.guide_id, + guide_version=locked.guide_version, + source_snapshot_id=locked.source_snapshot_id, + source_snapshot_sha256=locked.source_snapshot_sha256, + locked_guide_sha256=locked.locked_guide_sha256, + effective_policy_id=locked.effective_policy_id, + locked_artifact_policy_sha256=locked.effective_policy_sha256, + pre_submit_policy_id=locked.pre_submit_policy_id, + locked_checker_policy_sha256=locked.pre_submit_policy_sha256, + catalogue_id=request.plan.catalogue_id, + catalogue_version=request.plan.catalogue_version, + catalogue_manifest_sha256=request.plan.catalogue_manifest_sha256, + storage_scheme=custody.storage_scheme, + ) + evidence = await self._repository.persist( + context=context, + plan=request.plan, + execution=request.execution, + ) + pass_capability = ( + PreSubmitPassCapability( + evidence_set_id=evidence.evidence_set_id, + prepared_generation_id=request.prepared_generation_id, + predecessor_submission_id=request.predecessor_submission_id, + effective_plan_sha256=request.plan.plan_sha256, + archive_sha256=custody.archive_sha256, + semantic_manifest_sha256=custody.semantic_manifest_sha256, + storage_scheme=custody.storage_scheme, + ) + if request.execution.eligible and not evidence.replayed + else None + ) + failure_audit = ( + None + if request.execution.eligible + else pre_submit_failure_audit_payload( + actor_profile_id=request.actor_profile_id, + project_id=locked.project_id, + task_id=request.task_id, + prepared_generation_id=request.prepared_generation_id, + evidence=evidence, + execution=request.execution, + catalogue_id=request.plan.catalogue_id, + catalogue_version=request.plan.catalogue_version, + ) + ) + return PreSubmitEvidencePersistenceResult( + evidence=evidence, + pass_capability=pass_capability, + failure_audit=failure_audit, + ) diff --git a/backend/app/modules/artifacts/submission_materialization.py b/backend/app/modules/artifacts/submission_materialization.py index f82a37fab..60ab6334f 100644 --- a/backend/app/modules/artifacts/submission_materialization.py +++ b/backend/app/modules/artifacts/submission_materialization.py @@ -6,18 +6,27 @@ from typing import Protocol, final from uuid import UUID +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + from app.interfaces.artifact_operations import PreparedBundleMaterializationRequest from app.modules.actors.service_identities import ServiceIdentity from app.modules.artifacts.schemas import ArtifactAuthorityDeniedError from app.modules.artifacts.preparation import ArtifactPreparationService +from app.modules.artifacts.pre_submit_evidence import ( + PreSubmitEvidencePersistenceRequest, + PreSubmitEvidencePersistenceResult, + PreSubmitEvidenceService, +) from app.modules.artifacts.submission_archive import SubmissionArchiveInspector from app.modules.authorization.catalogue import ActionId from app.modules.authorization.prepared import PreparedAuthorizationHandle from app.modules.checkers.catalogue import PreSubmissionCheckerCatalogue from app.modules.checkers.pre_submit_execution import ( + ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES, DefaultPreSubmissionExecutionInput, - DefaultPreSubmissionExecutionResult, - DefaultPreSubmissionProcessor, + EffectivePreSubmissionProcessor, + PreSubmissionExecutionResult, PreSubmissionInfrastructureUnavailable, ) @@ -38,6 +47,7 @@ class PreSubmitMaterializationAuthorityFacts: archive_sha256: str archive_byte_count: int semantic_manifest_sha256: str + storage_scheme: str class PreSubmitMaterializationAuthorization(Protocol): @@ -80,16 +90,20 @@ def __init__( preparation: ArtifactPreparationService, archive_inspector: SubmissionArchiveInspector, catalogue: PreSubmissionCheckerCatalogue, + storage_scheme: str, ) -> None: self._authorization = authorization self._preparation = preparation self._archive_inspector = archive_inspector self._catalogue = catalogue + if storage_scheme not in ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES: + raise ValueError("pre-submit materializer storage scheme is invalid") + self._storage_scheme = storage_scheme async def materialize_prepared_bundle( self, request: PreparedBundleMaterializationRequest, - ) -> DefaultPreSubmissionExecutionResult: + ) -> PreSubmissionExecutionResult: """Consume fixed-service authority before any byte or workspace access.""" facts = self._authority_facts(request) await self._authorization.consume( @@ -98,7 +112,7 @@ async def materialize_prepared_bundle( prepared_authorization=request.prepared_authorization, facts=facts, ) - processor = DefaultPreSubmissionProcessor( + processor = EffectivePreSubmissionProcessor( archive_inspector=self._archive_inspector, catalogue=self._catalogue, execution_input=DefaultPreSubmissionExecutionInput( @@ -108,6 +122,8 @@ async def materialize_prepared_bundle( manifest=request.manifest, change_gate=request.change_gate, packet=request.packet, + prepared_generation_id=request.prepared_artifact.generation_id, + storage_scheme=self._storage_scheme, ), ) # Intentional friend call: this is the sole authority-gated caller, and @@ -144,4 +160,54 @@ def _authority_facts( archive_sha256=commitment.sha256, archive_byte_count=commitment.byte_count, semantic_manifest_sha256=request.manifest.sha256, + storage_scheme=self._storage_scheme, ) + + +class PreparedBundlePreSubmitEvidenceService: + """Execute in scratch, then persist exact evidence in a fresh transaction.""" + + def __init__( + self, + *, + session: AsyncSession, + materialization: PreparedBundleMaterializationService, + ) -> None: + self._session = session + self._materialization = materialization + + async def execute( + self, + request: PreparedBundleMaterializationRequest, + *, + actor_profile_id: UUID, + identity_link_id: UUID, + predecessor_submission_id: UUID | None, + ) -> PreSubmitEvidencePersistenceResult: + """Persist only after materialization has returned and cleaned its scratch lease.""" + if self._session.in_transaction(): + raise RuntimeError( + "pre-submit evidence orchestration requires a transaction-free session" + ) + commitment = request.prepared_artifact.commitment + prepared_generation_id = request.prepared_artifact.generation_id + execution = await self._materialization.materialize_prepared_bundle(request) + async with self._session.begin(): + await self._session.execute( + text("set transaction isolation level read committed") + ) + return await PreSubmitEvidenceService(self._session).persist( + PreSubmitEvidencePersistenceRequest( + actor_profile_id=actor_profile_id, + identity_link_id=identity_link_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + predecessor_submission_id=predecessor_submission_id, + prepared_generation_id=prepared_generation_id, + archive_sha256=commitment.sha256, + archive_byte_count=commitment.byte_count, + semantic_manifest_sha256=request.manifest.sha256, + plan=request.effective_plan, + execution=execution, + ) + ) diff --git a/backend/app/modules/checkers/compiler.py b/backend/app/modules/checkers/compiler.py index 04a31072a..56f4a2704 100644 --- a/backend/app/modules/checkers/compiler.py +++ b/backend/app/modules/checkers/compiler.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from functools import lru_cache +from collections.abc import Callable from typing import Any from app.core.hashing import canonical_json_hash @@ -12,6 +13,7 @@ PreSubmissionCheckerCatalogue, build_pre_submission_checker_catalogue, ) +from app.modules.checkers.pre_submit_defaults import is_canonical_relative_path PRE_SUBMIT_COMPILER_VERSION = "workstream-pre-submit-compiler-v0.1" PRE_SUBMIT_BUNDLE_SCHEMA_VERSION = "pre_submit_checker_bundle.v1" @@ -76,11 +78,18 @@ def build_project_pre_submit_checker_spec( if artifact.get("required", True) ] if required_artifacts: + artifact_paths = _project_unique_paths( + required_artifacts, + projector=_required_artifact_path, + field="required_artifacts", + ) rules.append( _rule( "require_file", ["required_artifacts"], - {"artifact_keys": [artifact["key"] for artifact in required_artifacts]}, + { + "artifact_paths": artifact_paths + }, ) ) required_evidence = [ @@ -89,11 +98,18 @@ def build_project_pre_submit_checker_spec( if evidence.get("required", True) ] if required_evidence: + evidence_paths = _project_unique_paths( + required_evidence, + projector=_required_evidence_path, + field="required_evidence", + ) rules.append( _rule( "require_minimum_evidence", ["required_evidence"], - {"evidence_keys": [evidence["key"] for evidence in required_evidence]}, + { + "evidence_paths": evidence_paths + }, ) ) forbidden_artifacts = effective_policy.get("forbidden_artifacts", []) @@ -350,30 +366,38 @@ def _validate_rule_coverage(effective_policy: dict[str, Any], rules: list[dict[s "allowed storage schemes", ) - required_artifact_keys = [ - artifact["key"] - for artifact in effective_policy.get("required_artifacts", []) - if artifact.get("required", True) - ] - if required_artifact_keys: + required_artifact_paths = _project_unique_paths( + [ + artifact + for artifact in effective_policy.get("required_artifacts", []) + if artifact.get("required", True) + ], + projector=_required_artifact_path, + field="required_artifacts", + ) + if required_artifact_paths: _require_blocking_rule(by_primitive, "require_file") _require_config_values( by_primitive["require_file"], - "artifact_keys", - required_artifact_keys, + "artifact_paths", + required_artifact_paths, "required artifacts", ) - required_evidence_keys = [ - evidence["key"] - for evidence in effective_policy.get("required_evidence", []) - if evidence.get("required", True) - ] - if required_evidence_keys: + required_evidence_paths = _project_unique_paths( + [ + evidence + for evidence in effective_policy.get("required_evidence", []) + if evidence.get("required", True) + ], + projector=_required_evidence_path, + field="required_evidence", + ) + if required_evidence_paths: _require_blocking_rule(by_primitive, "require_minimum_evidence") _require_config_values( by_primitive["require_minimum_evidence"], - "evidence_keys", - required_evidence_keys, + "evidence_paths", + required_evidence_paths, "required evidence", ) forbidden_patterns = [ @@ -538,14 +562,68 @@ def _policy_object_list(effective_policy: dict[str, Any], field: str) -> list[di raise PreSubmitCheckerCompilerError( f"effective project submission artifact policy {field} must be a list" ) + keys: set[str] = set() for value in values: if not isinstance(value, dict) or not isinstance(value.get("key"), str): raise PreSubmitCheckerCompilerError( f"effective project submission artifact policy {field} entries are invalid" ) + key = value["key"] + if not key or key in keys: + raise PreSubmitCheckerCompilerError( + f"effective project submission artifact policy {field} keys are invalid" + ) + keys.add(key) return values +def _required_artifact_path(item: dict[str, Any]) -> str: + """Require the explicit server-approved path attached to an artifact key.""" + value = item.get("path") + if not isinstance(value, str) or not is_canonical_relative_path(value): + raise PreSubmitCheckerCompilerError( + "effective project submission artifact policy path is invalid" + ) + return value + + +def _project_unique_paths( + items: list[dict[str, Any]], + *, + projector: Callable[[dict[str, Any]], str], + field: str, +) -> list[str]: + """Project policy identities into unique canonical server-owned paths.""" + paths = [projector(item) for item in items] + if len(paths) != len(set(paths)): + raise PreSubmitCheckerCompilerError( + f"effective project submission artifact policy {field} paths are ambiguous" + ) + return paths + + +def _required_evidence_path(item: dict[str, Any]) -> str: + """Project an evidence identity into Workstream's closed evidence namespace.""" + key = item.get("key") + if ( + not isinstance(key, str) + or not key + or any( + not (character.isascii() and (character.isalnum() or character in "._-")) + for character in key + ) + ): + raise PreSubmitCheckerCompilerError( + "effective project submission evidence key is unmappable" + ) + path = f"evidence/{key}" + if not is_canonical_relative_path(path): + raise PreSubmitCheckerCompilerError( + "effective project submission evidence key is unmappable" + ) + return path + + def _checker_names_for_rules(rules: list[dict[str, Any]]) -> list[str]: """Build stable checker-name projections from compiled primitive rules.""" catalogue = _compiler_catalogue() diff --git a/backend/app/modules/checkers/pre_submit_defaults.py b/backend/app/modules/checkers/pre_submit_defaults.py index 231d27dfe..2a357f60a 100644 --- a/backend/app/modules/checkers/pre_submit_defaults.py +++ b/backend/app/modules/checkers/pre_submit_defaults.py @@ -5,6 +5,7 @@ import re from collections.abc import Iterable, Sequence from dataclasses import dataclass +from pathlib import PurePosixPath GENERIC_ATTESTATIONS = frozenset({"ok", "done", "yes", "i agree", "confirmed"}) @@ -29,6 +30,18 @@ ) +def is_canonical_relative_path(value: str) -> bool: + """Return whether a path has one portable, traversal-free representation.""" + path = PurePosixPath(value) + return ( + value not in {"", ".", ".."} + and "\\" not in value + and not path.is_absolute() + and value == path.as_posix() + and ".." not in path.parts + ) + + def normalize_policy_token(value: str) -> str: """Normalize a policy key or machine term for deterministic comparison.""" normalized = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()) diff --git a/backend/app/modules/checkers/pre_submit_execution.py b/backend/app/modules/checkers/pre_submit_execution.py index 81176b835..9883e38fe 100644 --- a/backend/app/modules/checkers/pre_submit_execution.py +++ b/backend/app/modules/checkers/pre_submit_execution.py @@ -8,6 +8,8 @@ from pathlib import PurePosixPath import threading from typing import BinaryIO +from fnmatch import fnmatchcase +from uuid import UUID from app.core.hashing import canonical_json_hash from app.modules.artifacts.sources import ArtifactCommitment @@ -22,11 +24,11 @@ build_submission_manifest, ) from app.modules.checkers.catalogue import ( - PRE_SUBMISSION_RESULT_SCHEMA_VERSION, PreSubmissionCheckerCatalogue, PreSubmissionCheckerPhase, PreSubmissionCheckerState, PreSubmissionPlatformCapability, + PreSubmissionPolicyPrimitive, ) from app.modules.checkers.effective_plan import ( EffectivePreSubmissionExecutionPlan, @@ -34,6 +36,7 @@ ) from app.modules.checkers.pre_submit_defaults import ( attestation_validation_facts, + is_canonical_relative_path, matched_low_quality_patterns, ) @@ -49,6 +52,27 @@ _FORBIDDEN_EXACT_NAMES = frozenset({".env", "id_rsa", "id_ed25519"}) _FORBIDDEN_DIRECTORY_NAMES = frozenset({".git"}) _FORBIDDEN_SUFFIXES = (".pem", ".key") +_RESULT_MESSAGE_CODES = frozenset( + { + "advisory_disabled", + "attestation_missing", + "dependency_not_run", + "file_size_limit_exceeded", + "forbidden_artifact_present", + "package_size_limit_exceeded", + "packaging_requirement_failed", + "passed", + "policy_attestation_missing", + "quality_signal_warning", + "required_evidence_missing", + "required_file_missing", + "sensitive_path_forbidden", + "storage_scheme_not_allowed", + "submission_packet_invalid", + } +) +_RESULT_METADATA_KEYS = frozenset({"entry_count", "finding_count", "matched_category_count"}) +ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES = frozenset({"local", "s3"}) class DefaultPreSubmissionExecutionError(RuntimeError): @@ -59,8 +83,8 @@ class PreSubmissionInfrastructureUnavailable(DefaultPreSubmissionExecutionError) """Fail closed for an impossible or disabled mandatory execution state.""" -class DefaultPreSubmissionResultStatus(StrEnum): - """Closed non-review status vocabulary for one default checker result.""" +class PreSubmissionResultStatus(StrEnum): + """Closed non-review status vocabulary for every pre-submit result.""" PASSED = "passed" WARNING = "warning" @@ -84,26 +108,61 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) -class DefaultPreSubmissionEntryResult: - """One bounded path-redacted result bound to its exact plan entry.""" +class PreSubmissionResultDefinition: + """Authority-neutral identity for one catalogue-owned definition.""" + + dispatch_authority: str + definition_id: str + definition_version: str + public_name: str + source: str + + +@dataclass(frozen=True, slots=True) +class PreSubmissionResultPolicyTrace: + """Exact locked policy trace for one result.""" + + effective_plan_sha256: str + rule_instance_id: str | None + locked_policy_sha256: str + + +@dataclass(frozen=True, slots=True) +class PreSubmissionEntryResult: + """One canonical bounded and path-redacted pre-submit result.""" schema_version: str - plan_sha256: str - entry_id: str - entry_version: str - status: DefaultPreSubmissionResultStatus + definition: PreSubmissionResultDefinition + policy_trace: PreSubmissionResultPolicyTrace + phase: str + order: int + classification: str + severity: str + status: PreSubmissionResultStatus failure_code: str | None message_code: str - metadata: tuple[tuple[str, int | bool | str], ...] = () + metadata: tuple[tuple[str, int], ...] = () + + +@dataclass(frozen=True, slots=True) +class PreSubmissionExecutionCustody: + """Exact server-owned artifact facts observed by the sealed-tree execution.""" + + prepared_generation_id: UUID + archive_sha256: str + archive_byte_count: int + semantic_manifest_sha256: str + storage_scheme: str @dataclass(frozen=True, slots=True) -class DefaultPreSubmissionExecutionResult: - """Complete non-durable 04B2 phase result returned after scratch cleanup.""" +class PreSubmissionExecutionResult: + """Complete canonical platform-plus-project result after scratch cleanup.""" plan_sha256: str + custody: PreSubmissionExecutionCustody eligible: bool - entries: tuple[DefaultPreSubmissionEntryResult, ...] + entries: tuple[PreSubmissionEntryResult, ...] @dataclass(frozen=True, slots=True) @@ -116,10 +175,12 @@ class DefaultPreSubmissionExecutionInput: manifest: SubmissionManifest change_gate: SubmissionChangeGateResult packet: SubmissionPacketView + prepared_generation_id: UUID + storage_scheme: str -class DefaultPreSubmissionProcessor: - """Prepared-artifact processor that owns projection and default dispatch.""" +class EffectivePreSubmissionProcessor: + """Prepared-artifact processor owning the sole effective-plan dispatch.""" def __init__( self, @@ -138,9 +199,7 @@ def abort(self) -> None: """Prevent checker callback access after caller cancellation or timeout.""" self._aborted.set() - def process_blocking( - self, reader: BinaryIO, workspace: Path - ) -> DefaultPreSubmissionExecutionResult: + def process_blocking(self, reader: BinaryIO, workspace: Path) -> PreSubmissionExecutionResult: """Project and execute inside preparation's bounded blocking adapter.""" self._validate_input() return self._archive_inspector.project_and_run( @@ -153,7 +212,7 @@ def process_blocking( def _execute_unless_aborted( self, tree: SealedSubmissionTree, - ) -> DefaultPreSubmissionExecutionResult: + ) -> PreSubmissionExecutionResult: """Deny checker access after caller cancellation or deadline expiry.""" if self._aborted.is_set(): raise DefaultPreSubmissionExecutionError("pre_submission_execution_aborted") @@ -175,21 +234,17 @@ def _validate_input(self) -> None: ): raise PreSubmissionInfrastructureUnavailable("submission_change_identity_invalid") - def _execute(self, tree: SealedSubmissionTree) -> DefaultPreSubmissionExecutionResult: - """Run the ordered platform/default phase slice exactly once.""" - results: list[DefaultPreSubmissionEntryResult] = [] - statuses: dict[str, DefaultPreSubmissionResultStatus] = {} + def _execute(self, tree: SealedSubmissionTree) -> PreSubmissionExecutionResult: + """Run the complete ordered platform-plus-project plan exactly once.""" + results: list[PreSubmissionEntryResult] = [] + statuses: dict[str, PreSubmissionResultStatus] = {} blocked = False executed_ids = { entry.definition_id for entry in self._input.plan.entries - if entry.phase in _EXECUTED_PHASES + if entry.phase in _EXECUTED_PHASES | {PreSubmissionCheckerPhase.PROJECT_POLICY.value} } for entry in self._input.plan.entries: - if entry.phase not in _EXECUTED_PHASES: - continue - if entry.dispatch_kind != "platform_capability": - raise PreSubmissionInfrastructureUnavailable("pre_submission_dispatch_kind_invalid") definition = self._catalogue.definition(entry.definition_id) if entry.definition_id in statuses: raise PreSubmissionInfrastructureUnavailable("pre_submission_duplicate_result") @@ -212,44 +267,57 @@ def _execute(self, tree: SealedSubmissionTree) -> DefaultPreSubmissionExecutionR unmet = any( statuses.get(dependency) not in { - DefaultPreSubmissionResultStatus.PASSED, - DefaultPreSubmissionResultStatus.WARNING, - DefaultPreSubmissionResultStatus.ADVISORY_DISABLED, + PreSubmissionResultStatus.PASSED, + PreSubmissionResultStatus.WARNING, + PreSubmissionResultStatus.ADVISORY_DISABLED, } for dependency in entry.dependencies ) if blocked or unmet: result = self._result( entry, - DefaultPreSubmissionResultStatus.DEPENDENCY_NOT_RUN, + PreSubmissionResultStatus.DEPENDENCY_NOT_RUN, message_code="dependency_not_run", ) elif entry.state == PreSubmissionCheckerState.DISABLED.value: result = self._result( entry, - DefaultPreSubmissionResultStatus.ADVISORY_DISABLED, + PreSubmissionResultStatus.ADVISORY_DISABLED, message_code="advisory_disabled", ) else: result = self._dispatch(entry, tree) - if result.status is DefaultPreSubmissionResultStatus.FAILED: + if result.status is PreSubmissionResultStatus.FAILED: blocked = True statuses[entry.definition_id] = result.status results.append(result) if set(statuses) != executed_ids: raise PreSubmissionInfrastructureUnavailable("pre_submission_result_incomplete") - return DefaultPreSubmissionExecutionResult( + execution = PreSubmissionExecutionResult( plan_sha256=self._input.plan.plan_sha256, + custody=PreSubmissionExecutionCustody( + prepared_generation_id=self._input.prepared_generation_id, + archive_sha256=self._input.commitment.sha256, + archive_byte_count=self._input.commitment.byte_count, + semantic_manifest_sha256=self._input.manifest.sha256, + storage_scheme=self._input.storage_scheme, + ), eligible=not blocked, entries=tuple(results), ) + validate_pre_submission_execution_result(self._input.plan, execution) + return execution def _dispatch( self, entry: EffectivePreSubmissionPlanEntry, tree: SealedSubmissionTree, - ) -> DefaultPreSubmissionEntryResult: - """Dispatch one catalogue-validated platform capability.""" + ) -> PreSubmissionEntryResult: + """Dispatch one catalogue-validated platform capability or policy primitive.""" + if entry.dispatch_kind == "policy_primitive": + return self._dispatch_policy(entry, tree) + if entry.dispatch_kind != "platform_capability": + raise PreSubmissionInfrastructureUnavailable("pre_submission_dispatch_kind_invalid") try: capability = PreSubmissionPlatformCapability(entry.dispatch_capability) except ValueError as exc: @@ -260,7 +328,7 @@ def _dispatch( PreSubmissionCheckerPhase.CUSTODY.value, PreSubmissionCheckerPhase.IDENTITY.value, }: - return self._result(entry, DefaultPreSubmissionResultStatus.PASSED) + return self._result(entry, PreSubmissionResultStatus.PASSED) if entry.phase == PreSubmissionCheckerPhase.MATERIALIZATION.value: if tree.entries != self._input.manifest.entries: raise PreSubmissionInfrastructureUnavailable( @@ -268,7 +336,7 @@ def _dispatch( ) return self._result( entry, - DefaultPreSubmissionResultStatus.PASSED, + PreSubmissionResultStatus.PASSED, metadata=(("entry_count", len(tree.entries)),), ) if capability is PreSubmissionPlatformCapability.SUBMISSION_PACKET: @@ -308,25 +376,143 @@ def _dispatch( if matches: return self._result( entry, - DefaultPreSubmissionResultStatus.WARNING, + PreSubmissionResultStatus.WARNING, message_code="quality_signal_warning", metadata=(("matched_category_count", len(matches)),), ) - return self._result(entry, DefaultPreSubmissionResultStatus.PASSED) + return self._result(entry, PreSubmissionResultStatus.PASSED) + + def _dispatch_policy( + self, entry: EffectivePreSubmissionPlanEntry, tree: SealedSubmissionTree + ) -> PreSubmissionEntryResult: + """Evaluate one closed project-policy primitive using server-owned facts only.""" + try: + primitive = PreSubmissionPolicyPrimitive(entry.dispatch_capability) + except ValueError as exc: + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_policy_primitive_unknown" + ) from exc + config = entry.configuration.as_dict() + file_entries = tuple(item for item in tree.entries if item.sha256 is not None) + paths = {item.normalized_path for item in file_entries} + failure_count = 0 + message_code = "passed" + if primitive is PreSubmissionPolicyPrimitive.REQUIRE_FILE: + required = self._canonical_policy_paths(config.get("artifact_paths")) + failure_count = sum(path not in paths for path in required) + message_code = "required_file_missing" + elif primitive is PreSubmissionPolicyPrimitive.REQUIRE_MINIMUM_EVIDENCE: + required = self._canonical_policy_paths(config.get("evidence_paths")) + failure_count = sum(path not in paths for path in required) + message_code = "required_evidence_missing" + elif primitive is PreSubmissionPolicyPrimitive.FORBID_ARTIFACT: + patterns = self._string_list(config.get("patterns")) + failure_count = sum( + any(fnmatchcase(path, pattern) for pattern in patterns) for path in paths + ) + message_code = "forbidden_artifact_present" + elif primitive is PreSubmissionPolicyPrimitive.LIMIT_FILE_SIZE: + maximum = self._positive_limit(config, "maximum_file_size_bytes") + failure_count = sum(item.byte_count > maximum for item in file_entries) + message_code = "file_size_limit_exceeded" + elif primitive is PreSubmissionPolicyPrimitive.LIMIT_PACKAGE_SIZE: + maximum = self._positive_limit(config, "maximum_package_size_bytes") + failure_count = int(self._input.manifest.total_expanded_bytes > maximum) + message_code = "package_size_limit_exceeded" + elif primitive is PreSubmissionPolicyPrimitive.REQUIRE_ATTESTATION: + terms = self._string_list(config.get("terms")) + attestation = self._input.packet.contributor_attestation.casefold() + failure_count = sum(term.casefold() not in attestation for term in terms) + message_code = "policy_attestation_missing" + elif primitive is PreSubmissionPolicyPrimitive.VALIDATE_SUBMISSION_PACKET: + fields = set(self._string_list(config.get("fields"))) + known = {"summary", "worker_attestation", "contributor_attestation"} + if not fields.issubset(known): + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_policy_field_unmappable" + ) + failure_count = int("summary" in fields and not self._input.packet.summary.strip()) + failure_count += int( + bool(fields & {"worker_attestation", "contributor_attestation"}) + and not self._input.packet.contributor_attestation.strip() + ) + message_code = "submission_packet_invalid" + elif primitive is PreSubmissionPolicyPrimitive.REQUIRE_MANIFEST_FIELD: + failure_count = 0 + elif primitive is PreSubmissionPolicyPrimitive.VERIFY_HASH: + if config.get("algorithm") != "sha256": + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_policy_hash_algorithm_invalid" + ) + elif primitive is PreSubmissionPolicyPrimitive.REQUIRE_PACKAGING: + allowed = config.get("allowed_package_formats", []) + if not isinstance(allowed, list) or any(not isinstance(item, str) for item in allowed): + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_policy_configuration_invalid" + ) + failure_count = int(bool(allowed) and "zip" not in allowed) + message_code = "packaging_requirement_failed" + elif primitive is PreSubmissionPolicyPrimitive.ENFORCE_STORAGE_SCHEME: + schemes = self._string_list(config.get("schemes")) + failure_count = int(self._input.storage_scheme not in schemes) + message_code = "storage_scheme_not_allowed" + elif primitive is PreSubmissionPolicyPrimitive.WARN_LOW_QUALITY_GENERATED_ARTIFACT: + matches = matched_low_quality_patterns( + (self._input.packet.summary, self._input.packet.contributor_attestation, *paths) + ) + return self._result( + entry, + PreSubmissionResultStatus.WARNING if matches else PreSubmissionResultStatus.PASSED, + message_code="quality_signal_warning" if matches else "passed", + metadata=(("matched_category_count", len(matches)),) if matches else (), + ) + else: + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_policy_primitive_unknown" + ) + return self._blocking_or_pass(entry, failure_count, message_code) + + @staticmethod + def _string_list(value: object) -> tuple[str, ...]: + if not isinstance(value, list) or any( + not isinstance(item, str) or not item for item in value + ): + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_policy_configuration_invalid" + ) + return tuple(value) + + @classmethod + def _canonical_policy_paths(cls, value: object) -> tuple[str, ...]: + paths = cls._string_list(value) + if len(paths) != len(set(paths)) or any( + not is_canonical_relative_path(path) for path in paths + ): + raise PreSubmissionInfrastructureUnavailable("pre_submission_policy_path_unmappable") + return paths + + @staticmethod + def _positive_limit(config: dict[str, object], key: str) -> int: + value = config.get(key) + if type(value) is not int or value < 0: + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_policy_configuration_invalid" + ) + return value def _blocking_or_pass( self, entry: EffectivePreSubmissionPlanEntry, finding_count: int, message_code: str, - ) -> DefaultPreSubmissionEntryResult: + ) -> PreSubmissionEntryResult: """Build one blocking failure or successful bounded result.""" return self._result( entry, ( - DefaultPreSubmissionResultStatus.FAILED + PreSubmissionResultStatus.FAILED if finding_count - else DefaultPreSubmissionResultStatus.PASSED + else PreSubmissionResultStatus.PASSED ), failure_code=entry.failure_code if finding_count else None, message_code=message_code if finding_count else "passed", @@ -336,18 +522,31 @@ def _blocking_or_pass( def _result( self, entry: EffectivePreSubmissionPlanEntry, - status: DefaultPreSubmissionResultStatus, + status: PreSubmissionResultStatus, *, failure_code: str | None = None, message_code: str = "passed", - metadata: tuple[tuple[str, int | bool | str], ...] = (), - ) -> DefaultPreSubmissionEntryResult: + metadata: tuple[tuple[str, int], ...] = (), + ) -> PreSubmissionEntryResult: """Build one result bound to the exact plan and definition version.""" - return DefaultPreSubmissionEntryResult( - schema_version=PRE_SUBMISSION_RESULT_SCHEMA_VERSION, - plan_sha256=self._input.plan.plan_sha256, - entry_id=entry.definition_id, - entry_version=entry.definition_version, + return PreSubmissionEntryResult( + schema_version=entry.result_schema, + definition=PreSubmissionResultDefinition( + dispatch_authority="workstream.pre_submission_checker_catalogue", + definition_id=entry.definition_id, + definition_version=entry.definition_version, + public_name=entry.public_name, + source=entry.policy_trace_source, + ), + policy_trace=PreSubmissionResultPolicyTrace( + effective_plan_sha256=self._input.plan.plan_sha256, + rule_instance_id=entry.rule_instance_id, + locked_policy_sha256=self._input.plan.lineage.effective_policy_hash, + ), + phase=entry.phase, + order=entry.order, + classification=entry.classification, + severity="warning" if entry.classification == "advisory" else "blocking", status=status, failure_code=failure_code, message_code=message_code, @@ -355,6 +554,71 @@ def _result( ) +def validate_pre_submission_execution_result( + plan: EffectivePreSubmissionExecutionPlan, + execution: PreSubmissionExecutionResult, +) -> None: + """Reject any result envelope not produced by the exact immutable plan.""" + custody = execution.custody + try: + ArtifactCommitment.validate_sha256(custody.archive_sha256) + ArtifactCommitment.validate_sha256(custody.semantic_manifest_sha256) + except ValueError as exc: + raise PreSubmissionInfrastructureUnavailable( + "pre_submission_result_context_invalid" + ) from exc + if ( + type(custody.prepared_generation_id) is not UUID + or type(custody.archive_byte_count) is not int + or type(execution.eligible) is not bool + or custody.archive_byte_count < 0 + or custody.storage_scheme not in ALLOWED_PRE_SUBMIT_STORAGE_SCHEMES + or execution.plan_sha256 != plan.plan_sha256 + or len(execution.entries) != len(plan.entries) + ): + raise PreSubmissionInfrastructureUnavailable("pre_submission_result_context_invalid") + disqualified = False + for plan_entry, result in zip(plan.entries, execution.entries, strict=True): + expected_severity = "warning" if plan_entry.classification == "advisory" else "blocking" + if ( + type(result.status) is not PreSubmissionResultStatus + or result.schema_version != plan_entry.result_schema + or result.definition.dispatch_authority != "workstream.pre_submission_checker_catalogue" + or result.definition.definition_id != plan_entry.definition_id + or result.definition.definition_version != plan_entry.definition_version + or result.definition.public_name != plan_entry.public_name + or result.definition.source != plan_entry.policy_trace_source + or result.policy_trace.effective_plan_sha256 != plan.plan_sha256 + or result.policy_trace.rule_instance_id != plan_entry.rule_instance_id + or result.policy_trace.locked_policy_sha256 != plan.lineage.effective_policy_hash + or result.phase != plan_entry.phase + or result.order != plan_entry.order + or result.classification != plan_entry.classification + or result.severity != expected_severity + or result.message_code not in _RESULT_MESSAGE_CODES + or ( + result.failure_code + != ( + plan_entry.failure_code + if result.status is PreSubmissionResultStatus.FAILED + else None + ) + ) + or len(result.metadata) != len({key for key, _ in result.metadata}) + or any( + key not in _RESULT_METADATA_KEYS or type(value) is not int or value < 0 + for key, value in result.metadata + ) + ): + raise PreSubmissionInfrastructureUnavailable("pre_submission_result_context_invalid") + disqualified = disqualified or result.status in { + PreSubmissionResultStatus.FAILED, + PreSubmissionResultStatus.DEPENDENCY_NOT_RUN, + } + if execution.eligible == disqualified: + raise PreSubmissionInfrastructureUnavailable("pre_submission_result_context_invalid") + + def _is_high_confidence_sensitive(normalized_path: str) -> bool: """Match only the narrow Workstream-default sensitive-path set.""" parts = tuple(part.casefold() for part in PurePosixPath(normalized_path).parts) diff --git a/backend/app/modules/projects/models.py b/backend/app/modules/projects/models.py index 5cb33113b..cbb368a8b 100644 --- a/backend/app/modules/projects/models.py +++ b/backend/app/modules/projects/models.py @@ -398,6 +398,9 @@ class ProjectGuide(Base): __tablename__ = "project_guides" __table_args__ = ( UniqueConstraint("project_id", "version", name="uq_project_guides_project_version"), + UniqueConstraint( + "id", "project_id", "version", name="uq_project_guides_id_project_version" + ), CheckConstraint( "((selected_review_policy_id is null and " "selected_review_policy_generation is null and selected_review_policy_hash is null " diff --git a/backend/app/modules/tasks/models.py b/backend/app/modules/tasks/models.py index 789ec3494..a0853b3a8 100644 --- a/backend/app/modules/tasks/models.py +++ b/backend/app/modules/tasks/models.py @@ -130,6 +130,7 @@ class WorkstreamTask(Base): name="fk_workstream_tasks_locked_pre_submit_checker_hash", ), UniqueConstraint("id", "locked_guide_version", name="uq_workstream_tasks_id_locked_guide"), + UniqueConstraint("id", "project_id", name="uq_workstream_tasks_id_project"), UniqueConstraint( "id", "locked_post_submit_checker_policy_id", @@ -273,6 +274,9 @@ class TaskAssignment(Base): __tablename__ = "task_assignments" __table_args__ = ( + UniqueConstraint( + "id", "task_id", "contributor_id", name="uq_task_assignments_id_task_contributor" + ), Index( "uq_task_assignments_one_active_per_task", "task_id", diff --git a/backend/app/modules/tasks/pre_submit_context.py b/backend/app/modules/tasks/pre_submit_context.py new file mode 100644 index 000000000..c93134c82 --- /dev/null +++ b/backend/app/modules/tasks/pre_submit_context.py @@ -0,0 +1,177 @@ +"""Locked database context assembly for pre-submit evidence persistence.""" + +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.modules.actors.models import ActorIdentityLink, ActorProfile +from app.modules.projects.models import ( + EffectiveProjectSubmissionArtifactPolicy, + PreSubmitCheckerPolicy, + ProjectGuide, +) +from app.modules.tasks.models import Submission, TaskAssignment, WorkstreamTask + + +class PreSubmitLockedContextInvalid(RuntimeError): + """Fail closed when preparation no longer matches task-owned state.""" + + +@dataclass(frozen=True, slots=True) +class LockedPreSubmitContext: + """Exact locked rows revalidated immediately before evidence persistence.""" + + actor_profile_id: UUID + project_id: UUID + task_id: UUID + assignment_id: UUID + predecessor_submission_id: UUID | None + predecessor_submission_version: int | None + guide_id: UUID + guide_version: str + source_snapshot_id: UUID + source_snapshot_sha256: str + locked_guide_sha256: str + effective_policy_id: UUID + effective_policy_sha256: str + pre_submit_policy_id: UUID + pre_submit_policy_sha256: str + + +async def load_locked_pre_submit_context( + session: AsyncSession, + *, + actor_profile_id: UUID, + identity_link_id: UUID, + task_id: UUID, + assignment_id: UUID, + predecessor_submission_id: UUID | None, +) -> LockedPreSubmitContext: + """Lock and revalidate task, assignment, predecessor, guide, and policy lineage.""" + actor_profile = await session.scalar( + select(ActorProfile) + .where(ActorProfile.id == str(actor_profile_id)) + .with_for_update() + ) + identity_link = await session.scalar( + select(ActorIdentityLink) + .where(ActorIdentityLink.id == str(identity_link_id)) + .with_for_update() + ) + task = await session.scalar( + select(WorkstreamTask) + .where(WorkstreamTask.id == str(task_id)) + .with_for_update() + ) + assignment = await session.scalar( + select(TaskAssignment) + .where(TaskAssignment.id == str(assignment_id)) + .with_for_update() + ) + if ( + actor_profile is None + or actor_profile.status != "active" + or identity_link is None + or identity_link.actor_profile_id != str(actor_profile_id) + or identity_link.status != "active" + or task is None + or assignment is None + or assignment.task_id != str(task_id) + or assignment.contributor_id != str(actor_profile_id) + or assignment.status != "active" + or task.assigned_to != str(actor_profile_id) + or task.status not in {"in_progress", "needs_revision"} + or task.locked_guide_version is None + or task.locked_guide_source_snapshot_id is None + or task.locked_guide_source_snapshot_hash is None + or task.locked_effective_project_submission_artifact_policy_id is None + or task.locked_effective_project_submission_artifact_policy_hash is None + or task.locked_pre_submit_checker_policy_id is None + or task.locked_pre_submit_checker_bundle_hash is None + ): + raise PreSubmitLockedContextInvalid("pre_submit_locked_context_invalid") + latest_submission = await session.scalar( + select(Submission) + .where(Submission.task_id == str(task_id)) + .order_by(Submission.version.desc()) + .limit(1) + .with_for_update() + ) + latest_submission_id = latest_submission.id if latest_submission is not None else None + if latest_submission_id != ( + str(predecessor_submission_id) if predecessor_submission_id is not None else None + ): + raise PreSubmitLockedContextInvalid("pre_submit_predecessor_changed") + guide = await session.scalar( + select(ProjectGuide) + .where( + ProjectGuide.project_id == task.project_id, + ProjectGuide.version == task.locked_guide_version, + ) + .with_for_update() + ) + effective_policy = await session.scalar( + select(EffectiveProjectSubmissionArtifactPolicy) + .where( + EffectiveProjectSubmissionArtifactPolicy.id + == task.locked_effective_project_submission_artifact_policy_id, + EffectiveProjectSubmissionArtifactPolicy.project_id == task.project_id, + EffectiveProjectSubmissionArtifactPolicy.guide_version + == task.locked_guide_version, + EffectiveProjectSubmissionArtifactPolicy.effective_policy_hash + == task.locked_effective_project_submission_artifact_policy_hash, + ) + .with_for_update() + ) + checker_policy = await session.scalar( + select(PreSubmitCheckerPolicy) + .where( + PreSubmitCheckerPolicy.id == task.locked_pre_submit_checker_policy_id, + PreSubmitCheckerPolicy.project_id == task.project_id, + PreSubmitCheckerPolicy.guide_version == task.locked_guide_version, + PreSubmitCheckerPolicy.effective_policy_id + == task.locked_effective_project_submission_artifact_policy_id, + PreSubmitCheckerPolicy.effective_policy_hash + == task.locked_effective_project_submission_artifact_policy_hash, + PreSubmitCheckerPolicy.compiled_bundle_hash + == task.locked_pre_submit_checker_bundle_hash, + PreSubmitCheckerPolicy.lifecycle_status == "compiled", + ) + .with_for_update() + ) + if guide is None or effective_policy is None or checker_policy is None: + raise PreSubmitLockedContextInvalid("pre_submit_locked_context_changed") + guide_sha256 = canonical_json_hash( + { + "domain": "workstream.locked_task_guide.v1", + "project_id": task.project_id, + "guide_id": guide.id, + "guide_version": guide.version, + "source_snapshot_id": task.locked_guide_source_snapshot_id, + "source_snapshot_sha256": task.locked_guide_source_snapshot_hash, + } + ) + return LockedPreSubmitContext( + actor_profile_id=actor_profile_id, + project_id=UUID(task.project_id), + task_id=task_id, + assignment_id=assignment_id, + predecessor_submission_id=predecessor_submission_id, + predecessor_submission_version=( + latest_submission.version if latest_submission is not None else None + ), + guide_id=UUID(guide.id), + guide_version=guide.version, + source_snapshot_id=UUID(task.locked_guide_source_snapshot_id), + source_snapshot_sha256=task.locked_guide_source_snapshot_hash, + locked_guide_sha256=guide_sha256, + effective_policy_id=UUID(effective_policy.id), + effective_policy_sha256=effective_policy.effective_policy_hash, + pre_submit_policy_id=UUID(checker_policy.id), + pre_submit_policy_sha256=checker_policy.compiled_bundle_hash, + ) diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py index 0dbe3e125..2b95833c5 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -163,6 +163,7 @@ class TestLane: "tests/test_checker_catalogue.py", "tests/test_checkers.py", "tests/test_default_pre_submit_execution.py", + "tests/test_effective_pre_submit_execution.py", "tests/test_review_queue_persistence.py", "tests/test_review_lease_persistence.py", "tests/test_tasks.py", diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 582d359a6..7d50c6cc8 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,7 +21,7 @@ from scripts.run_isolated_tests import LOOPBACK, NAME_RE, ROLE_RE DDL_LOCK_DIRECTORY = Path("/tmp") -EXPECTED_PUBLIC_SCHEMA_SHA256 = "923a64584cd1580759012c2303a14146f881ae5e69d021ce99b0648c08fc765b" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "3b7e3ebf76d36ebb59e4a4eea6d798aef292b224f306a82b70c400afc395fc48" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", @@ -77,6 +77,8 @@ "payment_policies", "policy_mutation_idempotency_records", "pre_submit_checker_policies", + "pre_submit_evidence_results", + "pre_submit_evidence_sets", "project_compensation_adapter_bindings", "project_compensation_units", "project_create_idempotency_records", @@ -106,6 +108,8 @@ "guide_source_snapshot_items", "outbox_events", "policy_mutation_idempotency_records", + "pre_submit_evidence_results", + "pre_submit_evidence_sets", "contribution_award_definitions", "contribution_policies", "contribution_policy_versions", diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 62ee2087b..4332a029f 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -73,7 +73,7 @@ snapshot_existing_service_rows, ) -HEAD_REVISION = "0057_submission_policy_authority" +HEAD_REVISION = "0058_pre_submit_evidence" pytestmark = pytest.mark.postgres_schema_contract @@ -101,6 +101,99 @@ } +async def _pre_submit_evidence_schema(database_url: str) -> dict[str, object]: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + tables = { + row + for row in ( + await connection.execute( + text( + "select table_name from information_schema.tables where " + "table_schema=current_schema() and table_name like " + "'pre_submit_evidence_%'" + ) + ) + ).scalars() + } + constraints = { + row + for row in ( + await connection.execute( + text( + "select conname from pg_constraint where conname like " + "'%pre_submit_evidence%' or conname in " + "('uq_actor_identity_links_id_profile'," + "'uq_workstream_tasks_id_project'," + "'uq_task_assignments_id_task_contributor'," + "'uq_project_guides_id_project_version')" + ) + ) + ).scalars() + } + triggers = { + row + for row in ( + await connection.execute( + text( + "select tgname from pg_trigger where not tgisinternal and " + "tgname like 'pre_submit_evidence_%'" + ) + ) + ).scalars() + } + return {"tables": tables, "constraints": constraints, "triggers": triggers} + finally: + await engine.dispose() + + +def test_0058_pre_submit_evidence_empty_round_trip( + isolated_database_env: str, + migration_lock, +) -> None: + config = _alembic_config() + with migration_lock(): + try: + command.downgrade(config, "0057_submission_policy_authority") + prior = asyncio.run(_pre_submit_evidence_schema(isolated_database_env)) + command.upgrade(config, HEAD_REVISION) + installed = asyncio.run(_pre_submit_evidence_schema(isolated_database_env)) + command.downgrade(config, "0057_submission_policy_authority") + restored = asyncio.run(_pre_submit_evidence_schema(isolated_database_env)) + command.upgrade(config, HEAD_REVISION) + repeated = asyncio.run(_pre_submit_evidence_schema(isolated_database_env)) + finally: + command.upgrade(config, "head") + + assert prior == restored == {"tables": set(), "constraints": set(), "triggers": set()} + assert installed == repeated + assert installed["tables"] == { + "pre_submit_evidence_sets", + "pre_submit_evidence_results", + } + assert { + "uq_actor_identity_links_id_profile", + "uq_workstream_tasks_id_project", + "uq_task_assignments_id_task_contributor", + "uq_project_guides_id_project_version", + "fk_pre_submit_evidence_identity_actor", + "fk_pre_submit_evidence_assignment", + "fk_pre_submit_evidence_task_project", + "fk_pre_submit_evidence_guide_lineage", + "fk_pre_submit_evidence_task_source_snapshot", + "ck_pre_submit_evidence_results_result_failure_shape", + }.issubset(installed["constraints"]) + assert installed["triggers"] == { + "pre_submit_evidence_sets_immutable", + "pre_submit_evidence_sets_creation", + "pre_submit_evidence_sets_no_truncate", + "pre_submit_evidence_results_immutable", + "pre_submit_evidence_results_membership", + "pre_submit_evidence_results_no_truncate", + } + + async def _submission_policy_authority_shape(database_url: str) -> dict[str, object]: engine = create_async_engine(database_url) try: diff --git a/backend/tests/test_checker_catalogue.py b/backend/tests/test_checker_catalogue.py index fed04ce8c..313189df0 100644 --- a/backend/tests/test_checker_catalogue.py +++ b/backend/tests/test_checker_catalogue.py @@ -43,7 +43,9 @@ def _effective_policy() -> dict[str, object]: "workstream_default_policy": default_policy, "project_policy": {}, "required_packet_fields": default_policy["required_packet_fields"], - "required_artifacts": [{"key": "task.toml", "required": True}], + "required_artifacts": [ + {"key": "task.toml", "path": "task.toml", "required": True} + ], "required_evidence": [{"key": "results", "required": True}], "forbidden_artifacts": default_policy["forbidden_artifacts"], "attestation_terms": default_policy["attestation_terms"], @@ -324,8 +326,8 @@ def test_effective_plan_is_deterministic_and_commits_to_lineage_catalogue_and_co entry for entry in first.entries if entry.definition_id == "policy.file.require" ) exported = required_file.as_dict() - exported["configuration"]["artifact_keys"].append("mutated-after-hash") - assert required_file.as_dict()["configuration"]["artifact_keys"] == ["task.toml"] + exported["configuration"]["artifact_paths"].append("mutated-after-hash") + assert required_file.as_dict()["configuration"]["artifact_paths"] == ["task.toml"] assert hash(required_file) assert first.plan_sha256 == canonical_json_hash(first.as_dict()) diff --git a/backend/tests/test_checkers.py b/backend/tests/test_checkers.py index 77f875bbb..0433d2d0a 100644 --- a/backend/tests/test_checkers.py +++ b/backend/tests/test_checkers.py @@ -924,7 +924,7 @@ def test_pre_submit_compiler_rejects_skipped_evidence_coverage() -> None: spec = build_project_pre_submit_checker_spec(effective_policy, effective_policy_hash) for rule in spec["rules"]: if rule["primitive"] == "require_minimum_evidence": - rule["config"]["evidence_keys"] = [] + rule["config"]["evidence_paths"] = [] with pytest.raises(PreSubmitCheckerCompilerError, match="required evidence"): compile_project_pre_submit_checker_spec(effective_policy, effective_policy_hash, spec) diff --git a/backend/tests/test_ci_test_lanes.py b/backend/tests/test_ci_test_lanes.py index aacbe4e5a..7909b23d4 100644 --- a/backend/tests/test_ci_test_lanes.py +++ b/backend/tests/test_ci_test_lanes.py @@ -45,6 +45,7 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/test_checker_catalogue.py", "tests/test_checkers.py", "tests/test_default_pre_submit_execution.py", + "tests/test_effective_pre_submit_execution.py", "tests/test_review_lease_persistence.py", "tests/test_review_queue_persistence.py", "tests/test_tasks.py", diff --git a/backend/tests/test_default_pre_submit_execution.py b/backend/tests/test_default_pre_submit_execution.py index f63262569..fe1c9b43b 100644 --- a/backend/tests/test_default_pre_submit_execution.py +++ b/backend/tests/test_default_pre_submit_execution.py @@ -7,11 +7,15 @@ from dataclasses import replace from pathlib import Path import threading -from typing import cast +from types import SimpleNamespace +from typing import Any, cast import zipfile from uuid import uuid4 import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.core.hashing import canonical_json_hash from app.interfaces.artifact_operations import PreparedBundleMaterializationRequest @@ -33,6 +37,7 @@ ) from app.modules.artifacts.submission_materialization import ( DenyPreSubmitMaterializationAuthorization, + PreparedBundlePreSubmitEvidenceService, PreparedBundleMaterializationService, ) from app.modules.authorization.prepared import PreparedAuthorizationHandle @@ -46,9 +51,10 @@ compile_effective_pre_submission_execution_plan, ) from app.modules.checkers.pre_submit_execution import ( - DefaultPreSubmissionResultStatus, + PreSubmissionResultStatus, PreSubmissionInfrastructureUnavailable, SubmissionPacketView, + validate_pre_submission_execution_result, ) @@ -56,10 +62,31 @@ async def _bytes(value: bytes): yield value -def _archive(path: str = "task.toml") -> bytes: +@pytest.mark.asyncio +async def test_evidence_workflow_requires_transaction_free_session() -> None: + materialization = SimpleNamespace(materialize_prepared_bundle=lambda _request: None) + workflow = PreparedBundlePreSubmitEvidenceService( + session=cast(Any, SimpleNamespace(in_transaction=lambda: True)), + materialization=cast(Any, materialization), + ) + + with pytest.raises(RuntimeError, match="requires a transaction-free session"): + await workflow.execute( + cast(Any, object()), + actor_profile_id=uuid4(), + identity_link_id=uuid4(), + predecessor_submission_id=None, + ) + + +def _archive(path: str = "task.toml", *, extra_path: str | None = None) -> bytes: output = BytesIO() with zipfile.ZipFile(output, "w") as archive: archive.writestr(path, b"[task]\nname='proof'\n") + if extra_path is not None: + archive.writestr(extra_path, b"blocked\n") + if path != "evidence/results": + archive.writestr("evidence/results", b"verified\n") return output.getvalue() @@ -73,7 +100,7 @@ def _effective_policy() -> dict[str, object]: "workstream_default_policy": defaults, "project_policy": {}, "required_packet_fields": defaults["required_packet_fields"], - "required_artifacts": [{"key": "task.toml", "required": True}], + "required_artifacts": [{"key": "task.toml", "path": "task.toml", "required": True}], "required_evidence": [{"key": "results", "required": True}], "forbidden_artifacts": defaults["forbidden_artifacts"], "attestation_terms": defaults["attestation_terms"], @@ -158,10 +185,16 @@ def _handle() -> PreparedAuthorizationHandle: return cast(PreparedAuthorizationHandle, _TestPreparedAuthorizationHandle()) -async def _request(tmp_path: Path, *, path: str = "task.toml", catalogue=None): +async def _request( + tmp_path: Path, + *, + path: str = "task.toml", + extra_path: str | None = None, + catalogue=None, +): selected_catalogue = catalogue or build_pre_submission_checker_catalogue() plan = _plan(selected_catalogue) - data = _archive(path) + data = _archive(path, extra_path=extra_path) inspector = SubmissionArchiveInspector(SubmissionArchiveLimits()) manager = ArtifactScratchManager(root=tmp_path / "scratch", limits=_limits()) preparation = ArtifactPreparationService(manager) @@ -189,7 +222,7 @@ async def _request(tmp_path: Path, *, path: str = "task.toml", catalogue=None): summary="Completed exact project work.", contributor_attestation=( "I confirm no confidential client data, credentials, or copied source " - "material is included in this submission." + "material is included in this submission; rights_confirmed." ), ), ) @@ -204,6 +237,7 @@ async def test_authority_denial_precedes_workspace_and_checker_access(tmp_path: preparation=preparation, archive_inspector=inspector, catalogue=catalogue, + storage_scheme="s3", ) with pytest.raises(ArtifactAuthorityDeniedError): @@ -214,6 +248,360 @@ async def test_authority_denial_precedes_workspace_and_checker_access(tmp_path: manager.close() +@pytest.mark.asyncio +async def test_effective_evidence_workflow_persists_once_and_replays_exactly( + tmp_path: Path, + isolated_database_env: str, +) -> None: + request, inspector, manager, preparation, catalogue = await _request(tmp_path) + actor_id = uuid4() + identity_link_id = uuid4() + lineage = request.effective_plan.lineage + engine = create_async_engine(isolated_database_env) + custody_triggers = ( + ("projects", "project_creation_custody"), + ("project_guides", "guide_mutation_product_custody"), + ("guide_source_snapshots", "source_snapshot_product_custody"), + ("submission_artifact_policies", "submission_policy_creation_custody"), + ( + "effective_project_submission_artifact_policies", + "effective_submission_policy_custody", + ), + ("pre_submit_checker_policies", "pre_submit_policy_custody"), + ("review_policies", "review_policy_mutation_custody"), + ("revision_policies", "revision_policy_mutation_custody"), + ) + blocked_prepared = None + original_prepared_closed = False + tables = ( + "artifact_contents", + "artifact_replicas", + "artifact_put_attempts", + "submissions", + "checker_runs", + "review_queue_entries", + ) + try: + async with engine.begin() as connection: + params = { + "actor": str(actor_id), + "link": str(identity_link_id), + "project": str(lineage.project_id), + "guide": str(lineage.guide_id), + "snapshot": str(lineage.source_snapshot_id), + "snapshot_hash": lineage.source_snapshot_hash, + "submission_policy": str(uuid4()), + "effective_policy": str(lineage.effective_policy_id), + "effective_hash": lineage.effective_policy_hash, + "checker_policy": str(lineage.pre_submit_policy_id), + "checker_hash": lineage.pre_submit_policy_bundle_hash, + "post_policy": str(uuid4()), + "post_policy_hash": "sha256:" + "8" * 64, + "review_policy": str(uuid4()), + "review_policy_hash": "sha256:" + "7" * 64, + "revision_policy": str(uuid4()), + "revision_policy_hash": "sha256:" + "6" * 64, + "task": str(request.task_id), + "assignment": str(request.assignment_id), + } + await connection.execute( + text( + "insert into actor_profiles " + "(id,actor_kind,status,provisioning_method,created_by) values " + "(:actor,'human','active','automatic_first_access','test')" + ), + params, + ) + await connection.execute( + text( + "insert into actor_identity_links " + "(id,actor_profile_id,issuer,subject,subject_kind,status,linked_by," + "last_verified_at) values " + "(:link,:actor,'flow-test',:actor,'human','active','test',now())" + ), + params, + ) + for table, trigger in custody_triggers: + await connection.execute(text(f"alter table {table} disable trigger {trigger}")) + await connection.execute( + text( + "insert into projects (id,name,slug,status) values " + "(:project,'Evidence project',:project,'draft')" + ), + params, + ) + await connection.execute( + text( + "insert into project_guides " + "(id,project_id,version,status,content_markdown,created_by) values " + "(:guide,:project,'1','draft','# Guide','test')" + ), + params, + ) + await connection.execute( + text( + "insert into guide_source_snapshots " + "(id,project_id,guide_id,guide_version,manifest_schema_version," + "manifest_json,bundle_hash,captured_by) values " + "(:snapshot,:project,:guide,'1','1','{}'::json,:snapshot_hash,'test')" + ), + params, + ) + await connection.execute( + text( + "insert into submission_artifact_policies " + "(id,project_id,guide_id,guide_version,source_snapshot_id," + "source_snapshot_hash,policy_version,lifecycle_status,policy_body," + "policy_hash,derivation_source,source_material_refs,created_by) values " + "(:submission_policy,:project,:guide,'1',:snapshot,:snapshot_hash,'1'," + "'draft','{}'::json,:effective_hash,'test','[]'::json,'test')" + ), + params, + ) + await connection.execute( + text( + "insert into effective_project_submission_artifact_policies " + "(id,project_id,guide_id,guide_version,source_snapshot_id," + "source_snapshot_hash,submission_artifact_policy_id," + "submission_artifact_policy_hash,lifecycle_status,merge_algorithm_version," + "effective_policy,effective_policy_hash,created_by) values " + "(:effective_policy,:project,:guide,'1',:snapshot,:snapshot_hash," + ":submission_policy,:effective_hash,'approved','1','{}'::json," + ":effective_hash,'test')" + ), + params, + ) + await connection.execute( + text( + "insert into pre_submit_checker_policies " + "(id,project_id,guide_id,guide_version,source_snapshot_id," + "source_snapshot_hash,effective_policy_id,effective_policy_hash," + "lifecycle_status,compiler_version,compiled_bundle,compiled_bundle_hash," + "checker_names,checker_configs,created_by) values " + "(:checker_policy,:project,:guide,'1',:snapshot,:snapshot_hash," + ":effective_policy,:effective_hash,'compiled','1','{}'::json," + ":checker_hash,'[]'::json,'{}'::json,'test')" + ), + params, + ) + await connection.execute( + text( + "insert into checker_policies " + "(id,project_id,guide_id,guide_version,source_snapshot_id," + "source_snapshot_hash,effective_policy_id,effective_policy_hash," + "pre_submit_checker_policy_id,pre_submit_checker_bundle_hash," + "required_checkers,warning_checkers,blocking_severities,policy_hash," + "policy_body,lifecycle_status,created_by) values " + "(:post_policy,:project,:guide,'1',:snapshot,:snapshot_hash," + ":effective_policy,:effective_hash,:checker_policy,:checker_hash," + "'[]'::json,'[]'::json,'[]'::json,:post_policy_hash,'{}'::json," + "'compiled','test')" + ), + params, + ) + await connection.execute( + text( + "insert into review_policies " + "(id,project_id,guide_version,policy_generation,policy_hash," + "semantics_status,requires_second_review,allowed_decisions," + "minimum_finding_fields) values " + "(:review_policy,:project,'1',1,:review_policy_hash," + "'legacy_incomplete',false,'[]'::json,'[]'::json)" + ), + params, + ) + await connection.execute( + text( + "insert into revision_policies " + "(id,project_id,guide_version,policy_generation,policy_hash," + "semantics_status,max_revision_rounds,revision_deadline_hours," + "allowed_resubmission_states) values " + "(:revision_policy,:project,'1',1,:revision_policy_hash," + "'legacy_incomplete',1,24,'[]'::json)" + ), + params, + ) + await connection.execute( + text( + "insert into workstream_tasks " + "(id,project_id,locked_guide_version,locked_guide_source_snapshot_id," + "locked_guide_source_snapshot_hash," + "locked_effective_project_submission_artifact_policy_id," + "locked_effective_project_submission_artifact_policy_hash," + "locked_pre_submit_checker_policy_id,locked_pre_submit_checker_bundle_hash," + "locked_post_submit_checker_policy_id," + "locked_post_submit_checker_policy_version," + "locked_post_submit_checker_policy_hash," + "locked_post_submit_checker_policy_body," + "locked_review_policy_id,locked_review_policy_generation," + "locked_review_policy_hash,locked_revision_policy_id," + "locked_revision_policy_generation,locked_revision_policy_hash," + "source_type,title,description,skill_tags,status,assigned_to,created_by) values " + "(:task,:project,'1',:snapshot,:snapshot_hash,:effective_policy," + ":effective_hash,:checker_policy,:checker_hash,:post_policy,'1'," + ":post_policy_hash,'{}'::json,:review_policy,1,:review_policy_hash," + ":revision_policy,1,:revision_policy_hash,'manual','Evidence task'," + "'Evidence test task','[]'::json,'in_progress',:actor,'test')" + ), + params, + ) + await connection.execute( + text( + "insert into task_assignments " + "(id,task_id,contributor_id,assigned_by,status) values " + "(:assignment,:task,:actor,'test','active')" + ), + params, + ) + before = { + table: int(await connection.scalar(text(f"select count(*) from {table}")) or 0) + for table in tables + } + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as session: + workflow = PreparedBundlePreSubmitEvidenceService( + session=session, + materialization=PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + storage_scheme="s3", + ), + ) + first = await workflow.execute( + request, + actor_profile_id=actor_id, + identity_link_id=identity_link_id, + predecessor_submission_id=None, + ) + replay = await workflow.execute( + request, + actor_profile_id=actor_id, + identity_link_id=identity_link_id, + predecessor_submission_id=None, + ) + await request.prepared_artifact.close() + original_prepared_closed = True + blocked_prepared = await preparation.prepare( + _bytes(_archive("task.toml")), media_type="application/zip" + ) + blocked_inspection = await blocked_prepared.inspect(inspector) + blocked_manifest = build_submission_manifest(blocked_inspection) + blocked_request = replace( + request, + prepared_artifact=blocked_prepared, + inspection=blocked_inspection, + manifest=blocked_manifest, + change_gate=evaluate_submission_change( + commitment=blocked_prepared.commitment, + manifest=blocked_manifest, + predecessor=None, + predecessor_exists=False, + ), + packet=SubmissionPacketView( + summary="Completed exact project work.", + contributor_attestation="", + ), + ) + blocked = await workflow.execute( + blocked_request, + actor_profile_id=actor_id, + identity_link_id=identity_link_id, + predecessor_submission_id=None, + ) + async with engine.begin() as connection: + evidence_count = int( + await connection.scalar(text("select count(*) from pre_submit_evidence_sets")) or 0 + ) + result_count = int( + await connection.scalar(text("select count(*) from pre_submit_evidence_results")) + or 0 + ) + after = { + table: int(await connection.scalar(text(f"select count(*) from {table}")) or 0) + for table in tables + } + immutable_statements = ( + "update pre_submit_evidence_sets set terminal_status='blocked'", + "update pre_submit_evidence_results set status='failed'", + "delete from pre_submit_evidence_results", + "truncate pre_submit_evidence_results", + "insert into pre_submit_evidence_results " + "select '00000000-0000-0000-0000-000000000001'," + "evidence_set_id,result_order+1000," + "schema_version,dispatch_authority,definition_id || '.forged'," + "definition_version,public_name,source,phase,classification,severity,status," + "failure_code,message_code,effective_plan_sha256,rule_instance_id," + "locked_policy_sha256,now() from pre_submit_evidence_results limit 1", + ) + for statement in immutable_statements: + with pytest.raises(DBAPIError): + async with connection.begin_nested(): + await connection.execute(text(statement)) + with pytest.raises(DBAPIError, match="pre_submit_evidence_sets rows are immutable"): + async with connection.begin_nested(): + await connection.execute( + text( + "insert into pre_submit_evidence_sets select " + "(jsonb_populate_record(null::pre_submit_evidence_sets, " + "to_jsonb(existing_row) || jsonb_build_object(" + "'id','00000000-0000-0000-0000-000000000003'," + "'operation_identity','sha256:' || repeat('e',64)," + "'created_at',transaction_timestamp()))).* " + "from pre_submit_evidence_sets existing_row limit 1" + ) + ) + await connection.execute( + text( + "delete from pre_submit_evidence_sets " + "where id='00000000-0000-0000-0000-000000000003'" + ) + ) + with pytest.raises(DBAPIError, match="pre_submit_evidence_sets rows are immutable"): + async with connection.begin_nested(): + await connection.execute(text("truncate pre_submit_evidence_sets cascade")) + with pytest.raises(DBAPIError, match="creation timestamp is invalid"): + async with connection.begin_nested(): + await connection.execute( + text( + "insert into pre_submit_evidence_sets select " + "(jsonb_populate_record(null::pre_submit_evidence_sets, " + "to_jsonb(existing_row) || jsonb_build_object(" + "'id','00000000-0000-0000-0000-000000000002'," + "'operation_identity','sha256:' || repeat('f',64)," + "'created_at',existing_row.created_at - interval '1 day'))).* " + "from pre_submit_evidence_sets existing_row limit 1" + ) + ) + finally: + if blocked_prepared is not None: + await blocked_prepared.close() + if not original_prepared_closed: + await request.prepared_artifact.close() + manager.close() + async with engine.begin() as connection: + for table, trigger in reversed(custody_triggers): + await connection.execute(text(f"alter table {table} enable trigger {trigger}")) + await engine.dispose() + + assert first.evidence.replayed is False + assert replay.evidence.replayed is True + assert replay.evidence.evidence_set_id == first.evidence.evidence_set_id + assert first.pass_capability is not None + assert replay.pass_capability is None + assert first.failure_audit is None + assert blocked.pass_capability is None + assert blocked.evidence.replayed is False + assert blocked.failure_audit is not None + assert blocked.failure_audit["event_type"] == "pre_submission_check_failed" + assert blocked.failure_audit["failed_count"] >= 1 + assert "task.toml" not in repr(blocked.failure_audit) + assert evidence_count == 2 + assert result_count == 2 * len(request.effective_plan.entries) + assert after == before + + @pytest.mark.asyncio async def test_materializer_rejects_policy_lineage_mismatch_before_authority( tmp_path: Path, @@ -226,6 +614,7 @@ async def test_materializer_rejects_policy_lineage_mismatch_before_authority( preparation=preparation, archive_inspector=inspector, catalogue=catalogue, + storage_scheme="s3", ) with pytest.raises( @@ -243,7 +632,7 @@ async def test_materializer_rejects_policy_lineage_mismatch_before_authority( @pytest.mark.asyncio -async def test_default_executor_uses_plan_order_and_never_dispatches_project_rules( +async def test_effective_executor_uses_plan_order_and_dispatches_project_rules( tmp_path: Path, ) -> None: request, inspector, manager, preparation, catalogue = await _request(tmp_path) @@ -253,6 +642,7 @@ async def test_default_executor_uses_plan_order_and_never_dispatches_project_rul preparation=preparation, archive_inspector=inspector, catalogue=catalogue, + storage_scheme="s3", ) result = await service.materialize_prepared_bundle(request) @@ -260,17 +650,11 @@ async def test_default_executor_uses_plan_order_and_never_dispatches_project_rul expected = [ entry.definition_id for entry in request.effective_plan.entries - if entry.phase - in { - PreSubmissionCheckerPhase.CUSTODY.value, - PreSubmissionCheckerPhase.IDENTITY.value, - PreSubmissionCheckerPhase.MATERIALIZATION.value, - PreSubmissionCheckerPhase.DEFAULT_POLICY.value, - } + if entry.phase in set(PreSubmissionCheckerPhase) ] - assert [entry.entry_id for entry in result.entries] == expected - assert all(not entry.entry_id.startswith("policy.") for entry in result.entries) - assert all(entry.status is DefaultPreSubmissionResultStatus.PASSED for entry in result.entries) + assert [entry.definition.definition_id for entry in result.entries] == expected + assert any(entry.definition.definition_id.startswith("policy.") for entry in result.entries) + assert all(entry.status is PreSubmissionResultStatus.PASSED for entry in result.entries) assert result.eligible is True assert authority.facts is not None assert authority.action_id.value == "artifact.pre_submit.checker_input.materialize" @@ -289,6 +673,7 @@ async def test_default_executor_uses_plan_order_and_never_dispatches_project_rul assert authority.facts.archive_sha256 == request.prepared_artifact.commitment.sha256 assert authority.facts.archive_byte_count == request.prepared_artifact.commitment.byte_count assert authority.facts.semantic_manifest_sha256 == request.manifest.sha256 + assert authority.facts.storage_scheme == "s3" assert list((tmp_path / "scratch" / "workspaces").iterdir()) == [] await request.prepared_artifact.close() manager.close() @@ -304,16 +689,17 @@ async def test_blocking_default_stops_later_dependency_without_review_decision( preparation=preparation, archive_inspector=inspector, catalogue=catalogue, + storage_scheme="s3", ) result = await service.materialize_prepared_bundle(request) - by_id = {entry.entry_id: entry for entry in result.entries} + by_id = {entry.definition.definition_id: entry for entry in result.entries} assert by_id["artifact.sensitive_paths.high_confidence"].status is ( - DefaultPreSubmissionResultStatus.FAILED + PreSubmissionResultStatus.FAILED ) assert by_id["artifact.quality.placeholder_signal"].status is ( - DefaultPreSubmissionResultStatus.DEPENDENCY_NOT_RUN + PreSubmissionResultStatus.DEPENDENCY_NOT_RUN ) assert result.eligible is False assert all( @@ -337,14 +723,17 @@ async def test_disabled_advisory_is_explicit_and_not_skipped_success(tmp_path: P preparation=preparation, archive_inspector=inspector, catalogue=catalogue, + storage_scheme="s3", ) result = await service.materialize_prepared_bundle(request) advisory = next( - entry for entry in result.entries if entry.entry_id == "artifact.quality.placeholder_signal" + entry + for entry in result.entries + if entry.definition.definition_id == "artifact.quality.placeholder_signal" ) - assert advisory.status is DefaultPreSubmissionResultStatus.ADVISORY_DISABLED + assert advisory.status is PreSubmissionResultStatus.ADVISORY_DISABLED assert result.eligible is True await request.prepared_artifact.close() manager.close() @@ -362,14 +751,17 @@ async def test_quality_warning_emits_only_a_bounded_category_count(tmp_path: Pat preparation=preparation, archive_inspector=inspector, catalogue=catalogue, + storage_scheme="s3", ) result = await service.materialize_prepared_bundle(request) warning = next( - entry for entry in result.entries if entry.entry_id == "artifact.quality.placeholder_signal" + entry + for entry in result.entries + if entry.definition.definition_id == "artifact.quality.placeholder_signal" ) - assert warning.status is DefaultPreSubmissionResultStatus.WARNING + assert warning.status is PreSubmissionResultStatus.WARNING assert warning.metadata == (("matched_category_count", 2),) assert result.eligible is True await request.prepared_artifact.close() @@ -386,6 +778,7 @@ async def test_forged_plan_identity_fails_closed_and_cleans_workspace(tmp_path: preparation=preparation, archive_inspector=inspector, catalogue=catalogue, + storage_scheme="s3", ) with pytest.raises(PreSubmissionInfrastructureUnavailable, match="plan_identity"): @@ -453,6 +846,7 @@ def __getattr__(self, name): preparation=preparation, archive_inspector=inspector, catalogue=selected_catalogue, + storage_scheme="s3", ) with pytest.raises(PreSubmissionInfrastructureUnavailable, match=expected_message): @@ -488,6 +882,7 @@ async def test_disabled_mandatory_executor_state_fails_closed(tmp_path: Path) -> preparation=preparation, archive_inspector=inspector, catalogue=catalogue, + storage_scheme="s3", ) with pytest.raises(PreSubmissionInfrastructureUnavailable): @@ -499,7 +894,7 @@ async def test_disabled_mandatory_executor_state_fails_closed(tmp_path: Path) -> @pytest.mark.asyncio -async def test_default_execution_ignores_project_only_forbidden_rule(tmp_path: Path) -> None: +async def test_effective_execution_enforces_project_only_forbidden_rule(tmp_path: Path) -> None: catalogue = build_pre_submission_checker_catalogue() policy = _effective_policy() project_rule = {"pattern": "project-only.blocked"} @@ -520,7 +915,7 @@ async def test_default_execution_ignores_project_only_forbidden_rule(tmp_path: P ) request, inspector, manager, preparation, _ = await _request( tmp_path, - path="project-only.blocked", + extra_path="project-only.blocked", catalogue=catalogue, ) request = replace( @@ -534,12 +929,74 @@ async def test_default_execution_ignores_project_only_forbidden_rule(tmp_path: P preparation=preparation, archive_inspector=inspector, catalogue=catalogue, + storage_scheme="s3", ) result = await service.materialize_prepared_bundle(request) - assert result.eligible is True - assert all(not entry.entry_id.startswith("policy.") for entry in result.entries) + assert result.eligible is False + project_result = next( + entry + for entry in result.entries + if entry.definition.definition_id == "policy.artifact.forbid" + ) + assert project_result.status is PreSubmissionResultStatus.FAILED + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_effective_execution_enforces_server_owned_storage_scheme(tmp_path: Path) -> None: + request, inspector, manager, preparation, catalogue = await _request(tmp_path) + service = PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + storage_scheme="local", + ) + + result = await service.materialize_prepared_bundle(request) + + policy_result = next( + entry + for entry in result.entries + if entry.definition.definition_id == "policy.storage_scheme.enforce" + ) + assert policy_result.status is PreSubmissionResultStatus.FAILED + assert policy_result.message_code == "storage_scheme_not_allowed" + await request.prepared_artifact.close() + manager.close() + + +@pytest.mark.asyncio +async def test_canonical_result_validator_rejects_forged_definition(tmp_path: Path) -> None: + request, inspector, manager, preparation, catalogue = await _request(tmp_path) + service = PreparedBundleMaterializationService( + authorization=_AllowAuthority(), + preparation=preparation, + archive_inspector=inspector, + catalogue=catalogue, + storage_scheme="s3", + ) + result = await service.materialize_prepared_bundle(request) + first = result.entries[0] + forged = replace( + result, + entries=( + replace( + first, + definition=replace(first.definition, public_name="caller-selected"), + ), + *result.entries[1:], + ), + ) + + with pytest.raises( + PreSubmissionInfrastructureUnavailable, + match="pre_submission_result_context_invalid", + ): + validate_pre_submission_execution_result(request.effective_plan, forged) await request.prepared_artifact.close() manager.close() @@ -561,6 +1018,7 @@ def forbidden(*_args, **_kwargs): preparation=preparation, archive_inspector=inspector, catalogue=catalogue, + storage_scheme="s3", ) result = await service.materialize_prepared_bundle(request) @@ -577,21 +1035,22 @@ async def test_authorized_cancellation_cleans_before_propagating( request, inspector, manager, preparation, catalogue = await _request(tmp_path) entered = threading.Event() release = threading.Event() - from app.modules.checkers.pre_submit_execution import DefaultPreSubmissionProcessor + from app.modules.checkers.pre_submit_execution import EffectivePreSubmissionProcessor - original = DefaultPreSubmissionProcessor.process_blocking + original = EffectivePreSubmissionProcessor.process_blocking def blocking_process(self, reader, workspace): entered.set() assert release.wait(timeout=5) return original(self, reader, workspace) - monkeypatch.setattr(DefaultPreSubmissionProcessor, "process_blocking", blocking_process) + monkeypatch.setattr(EffectivePreSubmissionProcessor, "process_blocking", blocking_process) service = PreparedBundleMaterializationService( authorization=_AllowAuthority(), preparation=preparation, archive_inspector=inspector, catalogue=catalogue, + storage_scheme="s3", ) task = asyncio.create_task(service.materialize_prepared_bundle(request)) assert await asyncio.to_thread(entered.wait, 5) @@ -625,6 +1084,7 @@ def _project_file(self, *args, **kwargs): preparation=preparation, archive_inspector=_BlockingProjectionInspector(SubmissionArchiveLimits()), catalogue=catalogue, + storage_scheme="s3", ) task = asyncio.create_task(service.materialize_prepared_bundle(request)) assert await asyncio.to_thread(entered.wait, 5) @@ -647,16 +1107,16 @@ async def test_timeout_during_checker_access_cleans_workspace( request, inspector, manager, preparation, catalogue = await _request(tmp_path) entered = threading.Event() release = threading.Event() - from app.modules.checkers.pre_submit_execution import DefaultPreSubmissionProcessor + from app.modules.checkers.pre_submit_execution import EffectivePreSubmissionProcessor - original = DefaultPreSubmissionProcessor.process_blocking + original = EffectivePreSubmissionProcessor.process_blocking def blocking_process(self, reader, workspace): entered.set() assert release.wait(timeout=5) return original(self, reader, workspace) - monkeypatch.setattr(DefaultPreSubmissionProcessor, "process_blocking", blocking_process) + monkeypatch.setattr(EffectivePreSubmissionProcessor, "process_blocking", blocking_process) preparation._active[request.prepared_artifact._binding].deadline = ( asyncio.get_running_loop().time() + 0.01 ) @@ -665,6 +1125,7 @@ def blocking_process(self, reader, workspace): preparation=preparation, archive_inspector=inspector, catalogue=catalogue, + storage_scheme="s3", ) task = asyncio.create_task(service.materialize_prepared_bundle(request)) assert await asyncio.to_thread(entered.wait, 5) @@ -696,16 +1157,16 @@ def _seal_projected_content(self, root_fd, entries): assert release.wait(timeout=5) return super()._seal_projected_content(root_fd, entries) - from app.modules.checkers.pre_submit_execution import DefaultPreSubmissionProcessor + from app.modules.checkers.pre_submit_execution import EffectivePreSubmissionProcessor checker_called = threading.Event() - original_execute = DefaultPreSubmissionProcessor._execute + original_execute = EffectivePreSubmissionProcessor._execute def observed_execute(self, tree): checker_called.set() return original_execute(self, tree) - monkeypatch.setattr(DefaultPreSubmissionProcessor, "_execute", observed_execute) + monkeypatch.setattr(EffectivePreSubmissionProcessor, "_execute", observed_execute) if terminal == "timeout": preparation._active[request.prepared_artifact._binding].deadline = ( @@ -716,11 +1177,13 @@ def observed_execute(self, tree): preparation=preparation, archive_inspector=_BlockingSealInspector(SubmissionArchiveLimits()), catalogue=catalogue, + storage_scheme="s3", ) task = asyncio.create_task(service.materialize_prepared_bundle(request)) assert await asyncio.to_thread(entered.wait, 5) if terminal == "cancel": task.cancel() + await asyncio.sleep(0) else: await asyncio.sleep(0.02) release.set() diff --git a/backend/tests/test_effective_pre_submit_execution.py b/backend/tests/test_effective_pre_submit_execution.py new file mode 100644 index 000000000..608bd4b24 --- /dev/null +++ b/backend/tests/test_effective_pre_submit_execution.py @@ -0,0 +1,518 @@ +"""Focused proof for canonical effective pre-submit execution and evidence custody.""" + +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from app.core.hashing import canonical_json_hash +from app.modules.tasks.pre_submit_context import ( + PreSubmitLockedContextInvalid, + load_locked_pre_submit_context, +) +from app.modules.artifacts.pre_submit_evidence import ( + PreSubmitEvidenceConflict, + PreSubmitEvidenceContext, + PreSubmitPassCapability, + PersistedPreSubmitEvidence, + pre_submit_failure_audit_payload, + semantic_manifest_identity, +) +from app.modules.checkers.compiler import ( + PreSubmitCheckerCompilerError, + compile_effective_project_submission_artifact_policy, +) +from app.modules.checkers.pre_submit_execution import ( + PreSubmissionEntryResult, + PreSubmissionExecutionResult, + PreSubmissionExecutionCustody, + PreSubmissionResultDefinition, + PreSubmissionResultPolicyTrace, + PreSubmissionResultStatus, + PreSubmissionInfrastructureUnavailable, + validate_pre_submission_execution_result, +) + + +def _sha(character: str) -> str: + return "sha256:" + character * 64 + + +def _context() -> PreSubmitEvidenceContext: + return PreSubmitEvidenceContext( + actor_profile_id=uuid4(), + identity_link_id=uuid4(), + project_id=uuid4(), + task_id=uuid4(), + assignment_id=uuid4(), + predecessor_submission_id=None, + predecessor_submission_version=None, + prepared_generation_id=uuid4(), + archive_sha256=_sha("1"), + archive_byte_count=1024, + semantic_manifest_id=uuid4(), + semantic_manifest_sha256=_sha("2"), + guide_id=uuid4(), + guide_version="1", + source_snapshot_id=uuid4(), + source_snapshot_sha256=_sha("8"), + locked_guide_sha256=_sha("3"), + effective_policy_id=uuid4(), + locked_artifact_policy_sha256=_sha("4"), + pre_submit_policy_id=uuid4(), + locked_checker_policy_sha256=_sha("5"), + catalogue_id="workstream.pre_submission_checkers", + catalogue_version="v0.1", + catalogue_manifest_sha256=_sha("6"), + storage_scheme="s3", + ) + + +def test_evidence_operation_identity_binds_every_custody_fact() -> None: + context = _context() + identity = context.operation_identity(effective_plan_sha256=_sha("7")) + + assert identity == context.operation_identity(effective_plan_sha256=_sha("7")) + assert identity != replace(context, prepared_generation_id=uuid4()).operation_identity( + effective_plan_sha256=_sha("7") + ) + assert identity != context.operation_identity(effective_plan_sha256=_sha("8")) + + +def test_semantic_manifest_identity_is_server_deterministic() -> None: + assert semantic_manifest_identity(_sha("a")) == semantic_manifest_identity(_sha("a")) + assert semantic_manifest_identity(_sha("a")) != semantic_manifest_identity(_sha("b")) + + +def test_pass_capability_is_generation_bound_and_single_use() -> None: + evidence_set_id = uuid4() + generation_id = uuid4() + capability = PreSubmitPassCapability( + evidence_set_id=evidence_set_id, + prepared_generation_id=generation_id, + predecessor_submission_id=None, + effective_plan_sha256=_sha("7"), + archive_sha256=_sha("1"), + semantic_manifest_sha256=_sha("2"), + storage_scheme="s3", + ) + + with pytest.raises(PreSubmitEvidenceConflict, match="pre_submit_pass_capability_invalid"): + capability.consume( + prepared_generation_id=uuid4(), + predecessor_submission_id=None, + effective_plan_sha256=_sha("7"), + archive_sha256=_sha("1"), + semantic_manifest_sha256=_sha("2"), + storage_scheme="s3", + ) + + assert ( + capability.consume( + prepared_generation_id=generation_id, + predecessor_submission_id=None, + effective_plan_sha256=_sha("7"), + archive_sha256=_sha("1"), + semantic_manifest_sha256=_sha("2"), + storage_scheme="s3", + ) + == evidence_set_id + ) + with pytest.raises(PreSubmitEvidenceConflict, match="pre_submit_pass_capability_invalid"): + capability.consume( + prepared_generation_id=generation_id, + predecessor_submission_id=None, + effective_plan_sha256=_sha("7"), + archive_sha256=_sha("1"), + semantic_manifest_sha256=_sha("2"), + storage_scheme="s3", + ) + + +def test_compiler_projects_policy_artifact_path_not_contributor_label() -> None: + effective_policy = { + "workstream_default_policy": { + "required_packet_fields": [], + "forbidden_artifacts": [{"pattern": ".env"}], + "attestation_terms": ["rights_confirmed"], + }, + "project_policy": {}, + "required_packet_fields": [], + "required_artifacts": [{"key": "answer", "path": "outputs/final.md", "required": True}], + "required_evidence": [], + "forbidden_artifacts": [{"pattern": ".env"}], + "attestation_terms": ["rights_confirmed"], + "manifest_required": False, + "artifact_hash_required": False, + "allowed_storage_schemes": ["s3"], + "maximum_file_size_bytes": None, + "maximum_package_size_bytes": None, + "packaging": {"package_required": False}, + } + + compiled = compile_effective_project_submission_artifact_policy(effective_policy, _sha("9")) + required = next( + rule for rule in compiled.compiled_bundle["rules"] if rule["primitive"] == "require_file" + ) + + assert required["config"] == {"artifact_paths": ["outputs/final.md"]} + + +@pytest.mark.parametrize("path", [None, "", "../answer.md", "/answer.md", "a\\b"]) +def test_compiler_rejects_unmappable_artifact_paths(path: object) -> None: + policy = { + "workstream_default_policy": { + "required_packet_fields": ["summary", "worker_attestation"], + "forbidden_artifacts": [{"pattern": ".env"}], + "attestation_terms": ["rights_confirmed"], + }, + "project_policy": {}, + "required_packet_fields": ["summary", "worker_attestation"], + "required_artifacts": [{"key": "answer", "path": path, "required": True}], + "required_evidence": [], + "forbidden_artifacts": [{"pattern": ".env"}], + "attestation_terms": ["rights_confirmed"], + "manifest_required": False, + "artifact_hash_required": False, + "allowed_storage_schemes": ["s3"], + "maximum_file_size_bytes": None, + "maximum_package_size_bytes": None, + "packaging": {"package_required": False}, + } + + with pytest.raises(PreSubmitCheckerCompilerError, match="path is invalid"): + compile_effective_project_submission_artifact_policy(policy, _sha("9")) + + +@pytest.mark.parametrize("key", [".", ".."]) +def test_compiler_rejects_noncanonical_evidence_keys(key: str) -> None: + policy = { + "workstream_default_policy": {}, + "project_policy": {}, + "required_packet_fields": [], + "required_artifacts": [], + "required_evidence": [{"key": key, "required": True}], + "forbidden_artifacts": [], + "attestation_terms": [], + "manifest_required": False, + "artifact_hash_required": False, + "allowed_storage_schemes": ["s3"], + "maximum_file_size_bytes": None, + "maximum_package_size_bytes": None, + "packaging": {"package_required": False}, + } + + with pytest.raises(PreSubmitCheckerCompilerError, match="key is unmappable"): + compile_effective_project_submission_artifact_policy(policy, _sha("9")) + + +def test_compiler_rejects_duplicate_policy_keys_and_projected_paths() -> None: + base = { + "workstream_default_policy": {}, + "project_policy": {}, + "required_packet_fields": [], + "required_evidence": [], + "forbidden_artifacts": [], + "attestation_terms": [], + "manifest_required": False, + "artifact_hash_required": False, + "allowed_storage_schemes": ["s3"], + "maximum_file_size_bytes": None, + "maximum_package_size_bytes": None, + "packaging": {"package_required": False}, + } + duplicate_keys = { + **base, + "required_artifacts": [ + {"key": "answer", "path": "a.md"}, + {"key": "answer", "path": "b.md"}, + ], + } + duplicate_paths = { + **base, + "required_artifacts": [ + {"key": "answer", "path": "a.md"}, + {"key": "report", "path": "a.md"}, + ], + } + + with pytest.raises(PreSubmitCheckerCompilerError, match="keys are invalid"): + compile_effective_project_submission_artifact_policy(duplicate_keys, _sha("9")) + with pytest.raises(PreSubmitCheckerCompilerError, match="paths are ambiguous"): + compile_effective_project_submission_artifact_policy(duplicate_paths, _sha("9")) + + +@pytest.mark.asyncio +async def test_locked_context_revalidates_identity_assignment_and_policy_lineage() -> None: + actor_id = uuid4() + identity_link_id = uuid4() + project_id = uuid4() + task_id = uuid4() + assignment_id = uuid4() + guide_id = uuid4() + effective_policy_id = uuid4() + checker_policy_id = uuid4() + source_snapshot_id = uuid4() + session = SimpleNamespace( + scalar=AsyncMock( + side_effect=[ + SimpleNamespace(id=str(actor_id), status="active"), + SimpleNamespace( + id=str(identity_link_id), actor_profile_id=str(actor_id), status="active" + ), + SimpleNamespace( + id=str(task_id), + project_id=str(project_id), + assigned_to=str(actor_id), + status="in_progress", + locked_guide_version="1", + locked_guide_source_snapshot_id=str(source_snapshot_id), + locked_guide_source_snapshot_hash=_sha("1"), + locked_effective_project_submission_artifact_policy_id=str(effective_policy_id), + locked_effective_project_submission_artifact_policy_hash=_sha("2"), + locked_pre_submit_checker_policy_id=str(checker_policy_id), + locked_pre_submit_checker_bundle_hash=_sha("3"), + ), + SimpleNamespace( + id=str(assignment_id), + task_id=str(task_id), + contributor_id=str(actor_id), + status="active", + ), + None, + SimpleNamespace(id=str(guide_id), project_id=str(project_id), version="1"), + SimpleNamespace(id=str(effective_policy_id), effective_policy_hash=_sha("2")), + SimpleNamespace(id=str(checker_policy_id), compiled_bundle_hash=_sha("3")), + ] + ) + ) + + result = await load_locked_pre_submit_context( + session, + actor_profile_id=actor_id, + identity_link_id=identity_link_id, + task_id=task_id, + assignment_id=assignment_id, + predecessor_submission_id=None, + ) + + assert result.project_id == project_id + assert result.effective_policy_id == effective_policy_id + assert result.pre_submit_policy_id == checker_policy_id + assert result.guide_version == "1" + assert result.source_snapshot_id == source_snapshot_id + assert result.source_snapshot_sha256 == _sha("1") + assert session.scalar.await_count == 8 + + +@pytest.mark.asyncio +async def test_locked_context_rejects_revoked_identity_before_policy_reads() -> None: + actor_id = uuid4() + session = SimpleNamespace( + scalar=AsyncMock( + side_effect=[ + SimpleNamespace(id=str(actor_id), status="active"), + SimpleNamespace(actor_profile_id=str(actor_id), status="revoked"), + None, + None, + ] + ) + ) + + with pytest.raises(PreSubmitLockedContextInvalid, match="pre_submit_locked_context_invalid"): + await load_locked_pre_submit_context( + session, + actor_profile_id=actor_id, + identity_link_id=uuid4(), + task_id=uuid4(), + assignment_id=uuid4(), + predecessor_submission_id=None, + ) + + assert session.scalar.await_count == 4 + + +def test_failure_audit_projection_is_bounded_and_path_free() -> None: + actor_id = uuid4() + project_id = uuid4() + task_id = uuid4() + generation_id = uuid4() + evidence = PersistedPreSubmitEvidence(uuid4(), _sha("1"), False) + execution = PreSubmissionExecutionResult( + plan_sha256=_sha("2"), + custody=PreSubmissionExecutionCustody( + prepared_generation_id=generation_id, + archive_sha256=_sha("5"), + archive_byte_count=1, + semantic_manifest_sha256=_sha("6"), + storage_scheme="s3", + ), + eligible=False, + entries=( + PreSubmissionEntryResult( + schema_version="pre_submission_checker_result.v1", + definition=PreSubmissionResultDefinition( + dispatch_authority="workstream.pre_submission_checker_catalogue", + definition_id="policy.file.require", + definition_version="v1", + public_name="check_required_files", + source="locked_effective_project_submission_artifact_policy", + ), + policy_trace=PreSubmissionResultPolicyTrace( + effective_plan_sha256=_sha("2"), + rule_instance_id=_sha("3"), + locked_policy_sha256=_sha("4"), + ), + phase="project_policy", + order=10, + classification="mandatory_accountability", + severity="blocking", + status=PreSubmissionResultStatus.FAILED, + failure_code="pre_submission_checker_failed", + message_code="required_file_missing", + metadata=(("finding_count", 1),), + ), + ), + ) + + payload = pre_submit_failure_audit_payload( + actor_profile_id=actor_id, + project_id=project_id, + task_id=task_id, + prepared_generation_id=generation_id, + evidence=evidence, + execution=execution, + catalogue_id="workstream.pre_submission_checkers", + catalogue_version="v0.1", + ) + + assert payload["event_type"] == "pre_submission_check_failed" + assert payload["failed_count"] == 1 + serialized = repr(payload) + assert payload["result_outcomes"] == [ + { + "definition_id": "policy.file.require", + "definition_version": "v1", + "status": "failed", + "message_code": "required_file_missing", + } + ] + assert "finding_count" not in serialized + assert "/" not in serialized + + +def test_result_validation_rejects_failure_code_on_non_failed_result() -> None: + from app.modules.checkers.catalogue import build_pre_submission_checker_catalogue + from app.modules.checkers.effective_plan import ( + EffectivePreSubmissionPlanLineage, + compile_effective_pre_submission_execution_plan, + ) + + effective_policy = { + "workstream_default_policy": { + "required_packet_fields": ["summary", "worker_attestation"], + "forbidden_artifacts": [{"pattern": ".env"}], + "attestation_terms": ["rights_confirmed"], + }, + "project_policy": {}, + "required_packet_fields": ["summary", "worker_attestation"], + "required_artifacts": [], + "required_evidence": [], + "forbidden_artifacts": [{"pattern": ".env"}], + "attestation_terms": ["rights_confirmed"], + "manifest_required": False, + "artifact_hash_required": False, + "allowed_storage_schemes": ["s3"], + "maximum_file_size_bytes": None, + "maximum_package_size_bytes": None, + "packaging": {"package_required": False}, + } + policy_hash = canonical_json_hash(effective_policy) + compiled = compile_effective_project_submission_artifact_policy(effective_policy, policy_hash) + lineage = EffectivePreSubmissionPlanLineage( + project_id=uuid4(), + guide_id=uuid4(), + guide_version=1, + source_snapshot_id=uuid4(), + source_snapshot_hash=_sha("a"), + effective_policy_id=uuid4(), + effective_policy_hash=policy_hash, + pre_submit_policy_id=uuid4(), + pre_submit_policy_bundle_hash=compiled.compiled_bundle_hash, + ) + plan = compile_effective_pre_submission_execution_plan( + lineage=lineage, + effective_policy=effective_policy, + compiled_bundle=compiled.compiled_bundle, + catalogue=build_pre_submission_checker_catalogue(), + ) + forged = PreSubmissionExecutionResult( + plan_sha256=plan.plan_sha256, + custody=PreSubmissionExecutionCustody( + prepared_generation_id=uuid4(), + archive_sha256=_sha("b"), + archive_byte_count=1, + semantic_manifest_sha256=_sha("c"), + storage_scheme="s3", + ), + eligible=True, + entries=tuple( + PreSubmissionEntryResult( + schema_version=entry.result_schema, + definition=PreSubmissionResultDefinition( + dispatch_authority="workstream.pre_submission_checker_catalogue", + definition_id=entry.definition_id, + definition_version=entry.definition_version, + public_name=entry.public_name, + source=entry.policy_trace_source, + ), + policy_trace=PreSubmissionResultPolicyTrace( + effective_plan_sha256=plan.plan_sha256, + rule_instance_id=entry.rule_instance_id, + locked_policy_sha256=lineage.effective_policy_hash, + ), + phase=entry.phase, + order=entry.order, + classification=entry.classification, + severity="warning" if entry.classification == "advisory" else "blocking", + status=PreSubmissionResultStatus.PASSED, + failure_code="forged" if index == 0 else None, + message_code="passed", + ) + for index, entry in enumerate(plan.entries) + ), + ) + + with pytest.raises( + PreSubmissionInfrastructureUnavailable, + match="pre_submission_result_context_invalid", + ): + validate_pre_submission_execution_result(plan, forged) + + class _ForgedStatus: + value = "failed" + + forged_entry = replace( + forged.entries[0], + status=_ForgedStatus(), # type: ignore[arg-type] + failure_code=None, + ) + forged_status = replace( + forged, + eligible=True, + entries=(forged_entry, *forged.entries[1:]), + ) + + with pytest.raises( + PreSubmissionInfrastructureUnavailable, + match="pre_submission_result_context_invalid", + ): + validate_pre_submission_execution_result(plan, forged_status) + + with pytest.raises( + PreSubmissionInfrastructureUnavailable, + match="pre_submission_result_context_invalid", + ): + validate_pre_submission_execution_result(plan, replace(forged, eligible=1)) # type: ignore[arg-type] diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index a06c71a72..aa3867b82 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -801,15 +801,27 @@ admission and does not receive scratch paths or rerun the pre-submit plan. Before that cutover, hidden ART-04B2 establishes the execution boundary without exposing a route. The fixed materializer authorizes before any prepared-byte read or workspace reservation. One callback-scoped sealed tree is checked -against the server commitment and semantic manifest, only Workstream platform -and default phases execute, and the tree is cleaned before bounded non-durable -results return. Project-policy execution and immutable evidence remain 04B3. +against the server commitment and semantic manifest. ART-04B3 extends that same +callback to execute the locked project-policy phase and normalize every +platform and project result into one typed envelope. The tree is cleaned before +the transaction-bound evidence service reloads the actor, identity link, task, +assignment, predecessor, guide and locked policy rows and persists one +`PreSubmitEvidenceSet` with ordered `PreSubmitEvidenceResult` members. Evidence +identity is deterministic over the complete custody and locked-policy context; +exact replay returns the same durable set without minting another pass +capability, and changed facts fail closed. Only first persistence of a passing +execution produces a process-local, generation- and predecessor-bound +single-use capability for immediate admission continuation; a later attempt +must re-prepare the bundle. The evidence-set ID alone is never that capability. Blocking pre-submit failures prevent submission creation, create no submission row, no submission version, no task transition to `submitted`, and no submission-created audit event. Workstream still writes a task audit event named -`pre_submission_check_failed` with the structured checker result for project -operators. They do not return review decision values. +`pre_submission_check_failed` with bounded identifiers, catalogue identity, +stable codes, counts and categories for project operators. It excludes paths, +filenames, scratch/provider references, raw output, evidence contents, +credentials and free-form checker messages. Pre-submit results never return +review decision values. ## PostSubmitCheckerPolicy diff --git a/docs/architecture_lockdown.md b/docs/architecture_lockdown.md index 7523a2f4a..181575815 100644 --- a/docs/architecture_lockdown.md +++ b/docs/architecture_lockdown.md @@ -140,8 +140,11 @@ Hidden ART-04B2 authorizes the fixed materializer before any prepared-byte read, reserves the complete expanded workspace budget, verifies one canonical sealed tree against the 04A identities, and runs only platform/default catalogue entries. The callback-scoped tree and all paths are destroyed before bounded -non-durable results return. Project-policy execution and durable evidence remain -separate 04B3 work; AUTH activation remains XINT-06A. +results return. Hidden ART-04B3 extends that same plan and sealed-tree callback +to the locked project-policy entries, then reloads the exact task context and +persists one immutable platform-plus-project evidence set after scratch cleanup. +It creates no provider object, admission, Submission, or lifecycle effect; +AUTH activation remains XINT-06A. Tasks lock to the active guide version at creation or screening time before entering `READY`. Material guide changes require a new guide version. diff --git a/docs/glossary.md b/docs/glossary.md index 46ea84084..740f8316b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -229,10 +229,12 @@ The deterministic merge of Workstream's default submission artifact policy and t The server-generated project checker matrix produced from the effective project submission artifact policy and one immutable default-catalogue snapshot. The compiled bundle embeds the catalogue version, canonical manifest digest, ordered entry ID/version/configuration hashes, and enabled/disabled state. Its compiled bundle hash therefore commits transitively to that exact snapshot, and each task locks that hash before entering the contributor pipeline. Runtime uses the same snapshot to derive the effective-plan hash. It runs against the uploaded ZIP in bounded scratch before Workstream creates a submission. A failed preparation returns `pre_submission_checker_failed` with bounded same-request details. The old standalone preflight route remains frozen legacy behavior until ART-05B removes it with the legacy Submission path; it is not an alternate authority for this policy. Results never use review decision values: `accept`, `needs_revision`, or `reject`. -The hidden Workstream-default execution slice uses the closed entry statuses +The hidden 04B2 Workstream-default execution slice and the hidden 04B3 complete +effective execution use the closed entry statuses `passed`, `warning`, `failed`, `advisory_disabled`, and `dependency_not_run`. These are checker-execution facts, not review decisions. -Project-policy entries execute later through the same plan. +04B3 executes locked project-policy entries through that same plan and persists +one immutable ordered platform-plus-project evidence set after scratch cleanup. ## pre_submission_checker_failed diff --git a/docs/spec_artifact_storage_service.md b/docs/spec_artifact_storage_service.md index 78af9caa5..c2cceaa0d 100644 --- a/docs/spec_artifact_storage_service.md +++ b/docs/spec_artifact_storage_service.md @@ -1195,6 +1195,46 @@ durable ambiguity. Submission creation locks and consumes the exact admission; post-submit materialization resolves its immutable binding and recomputes integrity before checker execution. +### PreSubmitEvidenceSet + +The sealed-tree callback returns one canonical ordered result containing both +platform/default and locked project-policy entries. It performs no database +write. After scratch cleanup, a separate caller-transaction service locks and +reloads the active actor and identity link, task, active assignment, immediate +predecessor, locked guide lineage, effective submission-artifact policy and +compiled pre-submit policy. Drift fails closed before evidence persistence. + +`PreSubmitEvidenceSet` stores the exact actor/task/assignment/predecessor, +prepared generation, archive digest/size, semantic-manifest identity, locked +guide and policy hashes, effective-plan identity and catalogue identity. +Ordered `PreSubmitEvidenceResult` rows store the closed definition, policy +trace, phase, classification, status and bounded codes directly; required +provenance is never hidden in generic metadata. A result-manifest digest binds +the complete canonical envelope. + +The operation identity hashes every input custody and locked-policy fact. A +database uniqueness constraint permits one set for that identity. Exact replay +returns the existing durable evidence set but never returns a new pass +capability; the caller must re-prepare the exact bundle before a later +submission attempt. Changed facts or changed deterministic results conflict. +Migration `0058_pre_submit_evidence` installs the normalized set and +result tables plus composite identity-link, assignment, task/project, locked +guide/policy and predecessor lineage constraints. Set and result rows reject +update, delete and truncate; result membership closes with the creating +transaction, and populated evidence prevents destructive downgrade. Replay +revalidates every ordered result member before trusting the set. Blocking +results create evidence only—never an ArtifactContent, +provider write, admission, Submission or lifecycle effect. + +A passing evidence set additionally yields a process-local single-use +capability bound to the prepared generation, immediate predecessor and +effective-plan hash. ART-04C must consume that capability immediately; the +durable evidence-set ID cannot substitute for it. The only failure audit +projection is `pre_submission_check_failed`, containing bounded IDs, catalogue +identity, stable codes, counts and categories without submitted paths, +filenames, scratch/provider references, credentials, raw output or free-form +messages. + ### SubmissionBundleAdmission One immutable verified preparation with closed status: