diff --git a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/STATUS.md b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/STATUS.md index 4e87ad171..162b43ff7 100644 --- a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/STATUS.md @@ -1,13 +1,14 @@ # Status - Initiative state: AUTH module-boundary recovery active -- Current branch: `codex/ws-auth-003-01-boundary-foundation` -- Base: `origin/main` at `aa0854ad` +- Current consumer repair branch: `codex/ws-pol-003-03a-hidden-compilation-resume` +- Foundation merge: PR #305 at `3c260e20` - Current chunk: `WS-AUTH-003-01` boundary foundation -- Chunk state: AUTH boundary foundation awaiting human merge -- Implementation: focused deterministic proof, required internal/external - review repairs, and exact-head hosted CI passed +- Chunk state: AUTH boundary foundation merged through PR #305 +- Implementation: public boundary foundation is merged; incremental capability + repair is active through POL-03A - Current import debt: inventoried in `IMPORT_LEDGER.md` -- POL-03A: preserved separately at `1a7242f2`; first incremental repair after foundation merge +- POL-03A: active on a fresh branch from merged main; preserved WIP `1a7242f2` + is reference evidence only and will not be replayed wholesale - REV rule: no private AUTH/ART import exception at `allow_reviews` -- Human approval required: review and merge the completed foundation PR after hosted gates pass +- Human approval required: review and merge each incremental capability repair diff --git a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json index 6833f74db..35b7bd372 100644 --- a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json +++ b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json @@ -170,11 +170,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "291531995acdf513a50a726b108462730ec3f5f5004bc541b1c03f3ca0be6212", - "end_line": 14097, + "content_sha256": "a22db9cfc5dc48abeb980b4dab760748caffd9ef9894dc20e388ce10ba423f80", + "end_line": 14093, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 14097, + "observed_lines": 14093, "path": "backend/tests/test_alembic.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", diff --git a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/STATUS.md b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/STATUS.md index 1becba118..282dd75b1 100644 --- a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/STATUS.md +++ b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/STATUS.md @@ -29,7 +29,7 @@ determine transient implementation ownership. | Canonical CHECKER/POL post-submit registry | POL-01 | Present; remaining POL-002 work must be reframed as executor ownership, not inference | | POL-01 strict manifest | POL-02 | Merged PR #299 | | POL-02 adapter | POL-03A | Merged PR #301 | -| Hidden POL-03A compilation manifest | AUTH-12I compilation request/execute activation | Proposed | +| Hidden POL-03A compilation manifest | AUTH-12I compilation request/execute activation | Active; not merged | | AUTH-12I | POL-03B authorized persistence | Not yet implemented | | Hidden POL-04A unified setup-service manifest | AUTH-12B2 setup-ledger activation | Not yet implemented | | Hidden POL-05A approval manifest | AUTH-12F4 approval activation | Not yet implemented | @@ -45,6 +45,6 @@ before any is started, its contract must be expanded on then-current main with explicit allowed/not-allowed paths, runnable verification commands, and named reviewer tracks. They cannot authorize implementation in their current form. -The next dependency-ordered boundary is `WS-POL-003-03A`. It and later chunks -and corresponding AUTH gates remain planned and inactive until -their exact dependencies and executable contracts are current. +`WS-POL-003-03A` is the active dependency-ordered boundary. Its executable +contract was reconciled on merged AUTH boundary foundation before +implementation. Later chunks and AUTH gates remain planned and inactive. diff --git a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-03A-hidden-compilation-foundation.md b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-03A-hidden-compilation-foundation.md index 6e1bd345c..7d2af5e75 100644 --- a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-03A-hidden-compilation-foundation.md +++ b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-03A-hidden-compilation-foundation.md @@ -1,37 +1,359 @@ # Chunk Contract: WS-POL-003-03A - Hidden Compilation Foundation -Status: Proposed after 02; inactive. Risk: L1. +Status: Implementation and internal review complete; awaiting hosted CI and +human merge. Risk: L1. ## Goal -Add immutable attempt/compilation persistence, trusted validation, append-only -supersession, and deny-by-default request/execute seams without making a model -call or a live product mutation. +Install the hidden durable foundation for one logical unified project-guide +compilation per exact setup generation. Add immutable attempt/result custody, +strict validation, append-only supersession, crash-safe repository transitions, +and deny-only authorization seams. Make no model call and change no live product +behavior. + +## Why this chunk exists + +POL-02 can perform one strict provider call, but Workstream cannot safely use it +until an attempt is reserved before I/O, uncertain provider outcomes retain the +same key, accepted output survives a crash before projection, and current +compilation selection cannot fork. AUTH also needs the exact hidden resource +manifest before AUTH-12I may activate request or execute authority. + +This is the first capability repair under `WS-AUTH-003`: every new POL import of +AUTH uses only `app.modules.authorization.api`; no new private AUTH edge is +permitted. Existing unrelated private-import debt remains frozen. + +## Exact modular shape + +```text +app/modules/authorization/api/project_guide_compilation.py + dependency-free request/execute facts and opaque capability Protocol + +app/modules/projects/guide_compilation/ + authorization.py deny-only project-side seam using AUTH public API + contracts.py attempt states, commands, and validated durable values + models.py attempt and immutable compilation tables only + repository.py short-transaction reservation/recovery/CAS operations + validation.py canonical result/context/hash revalidation +``` + +The package is internal to Projects. Cross-module consumers must later use a +Projects public API rather than import these files. This chunk creates no such +consumer and no composition-root wiring. + +## Exact attempt identity and states + +One attempt is uniquely bound to project, guide, guide version, source snapshot +and hash, setup run/generation, canonical input/material hashes, both catalogue +snapshot identities and hashes, configured agent identity/version, instruction +version, and one server-derived provider idempotency key. + +```text +compilation_reserved -> compilation_provider_uncertain -> provider_result_accepted -> compilation_persisted +compilation_reserved -------------------------------> provider_result_accepted +compilation_reserved/compilation_provider_uncertain -> compilation_invalid_terminal +``` + +`compilation_invalid_terminal` and `compilation_persisted` are terminal. Invalid or unsafe output +consumes that generation. Transport uncertainty reconciles only under the +original attempt and provider key. Reservation commits before any future +provider I/O; this chunk performs no provider I/O. + +`provider_result_accepted` means provider-result custody only. It is not Project Manager +approval, the Review decision `accept`, guide activation, effective policy, +setup success, or contribution/reputation/compensation evidence. + +Accepted canonical output and complete/component hashes are stored on the +attempt before inserting one immutable compilation. A later compilation names +its predecessor through `supersedes_compilation_id`; old rows are never updated +to express currentness. Database uniqueness plus repository compare-and-swap +allows one root and one child for an expected predecessor. + +## Exact hidden authorization surface + +`authorization/api/project_guide_compilation.py` defines only these frozen +dataclasses and Protocol; it imports no Projects or AUTH-private type: + +- `ProjectGuideCompilationRequestFacts`: `project_id`, `guide_id`, + `guide_version`, `source_snapshot_id`, `source_snapshot_hash`, `setup_run_id`, + `setup_generation`, `canonical_input_hash`, `guide_material_hash`, + `operation_id`, `request_id`, `idempotency_key`, + `pre_catalogue_id`, `pre_catalogue_version`, `pre_catalogue_schema_version`, + `pre_catalogue_manifest_hash`, `post_catalogue_id`, + `post_catalogue_version`, `post_catalogue_schema_version`, + `post_catalogue_manifest_hash`, `agent_identity`, `agent_version`, + `instruction_version`, and optional `expected_predecessor_compilation_id`. +- `ProjectGuideCompilationExecutePreflightFacts`: every request fact plus exact + `attempt_id` and `provider_idempotency_key`. +- `ProjectGuideCompilationExecutePersistFacts`: every execute-preflight fact + plus `result_hash`, `sufficiency_component_hash`, + `artifact_policy_component_hash`, `requirement_inventory_component_hash`, + `pre_submit_policy_component_hash`, `post_submit_policy_component_hash`, + `capability_suggestions_component_hash`, and `setup_notes_component_hash`. +- Final persist facts also carry `resource_context_digest`, which must equal + the canonical public-AUTH digest of the exact actor profile, identity link, + fixed service identity, action, lineage, request, attempt, accepted result, + and component facts. +- `ProjectGuideCompilationAuthorizationPort[PreparedHandleT]`: + `prepare_request`, `consume_request`, `authorize_execute_preflight`, + `prepare_execute_persist`, and `consume_execute_persist`. Every method takes + exact `ActorIdentityFacts`, one exact fact type, and the action-specific + opaque handle where applicable. Consume returns the committed authorization + decision-event UUID. + +Public facts use only UUIDs, bounded canonical strings, positive integers, and +the named immutable fields above. They reject `dict[str, Any]`, mutable +collections, raw guide text, raw/provider output, paths, URLs, credentials, +reasoning/traces, and unbounded values. + +The future actions are: + +- `project.guide_compilation.request`: human Project Manager request/recovery; +- `project.guide_compilation.execute`: fixed `workstream.project.setup` + preflight and fresh transaction-bound accepted-result persistence. + +Facts bind actor/identity link, exact project/guide/source/setup lineage, +operation/request/idempotency identity, catalogue and agent/instruction +identity, and—on final execute consumption—the accepted result/component +hashes. The project-side default always raises a stable AUTH boundary denial. +No handle is serializable or durable. The migration admits the exact planned +`project.guide_compilation.execute` action/permission pair and the bounded +`project_guide_compilation_attempt` resource type only to the audit evidence, +permission-registry, and privacy check constraints so an immutable hidden +compilation cannot reference unrelated authority evidence. It does not +register or activate that action in +the catalogue, evaluator, service matrix, or runtime composition; execution +remains unavailable until AUTH-12I. + +The generic handle parameter never defines or exposes a concrete handle. +Production handles in AUTH-12I must be process-local, non-dataclass, +non-Pydantic, non-JSON, have no public serializable fields, and bind the exact +action, actor/link or service identity, request/idempotency identity, attempt, +resource facts, database session, and final transaction. They are never copied, +reconstructed, stored, logged, or placed in Celery. In 03A the deny-only seam +rejects every handle/value and wrong request/execute combination before an +instrumented repository/session is touched. AUTH-12I must additionally prove +copied, stale, cross-project, cross-generation, wrong-service, wrong-action, +wrong-session, and wrong-transaction real-handle denials before activation. + +Project Manager request/recovery can only create the first reservation or +observe/resume the same exact attempt and provider key. Once an attempt is +`compilation_provider_uncertain`, `provider_result_accepted`, +`compilation_persisted`, or `compilation_invalid_terminal`, it cannot +allocate another key or bypass fixed-service execute custody. + +## AUTH ledger delta + +Private import ledger: **0 removals, 0 additions**. This is a new hidden +consumer and imports only `app.modules.authorization.api`. No existing Projects +AUTH consumer is touched. The public API leak/reachability test must include the +new module and prove it reaches no Projects or private AUTH module. + +## Exact durable schema + +Migration file `0062_project_guide_compilation_foundation.py` has +`revision = "0062_guide_compilation"` and +`down_revision = "0061_submission_admission"`; `HEAD_REVISION` changes to the +same new revision. This is valid only while that remains the sole main head. + +`project_guide_compilation_attempts` requires: + +- unique `uq_compilation_attempt_setup_generation(setup_run_id, + setup_generation)` and `uq_compilation_attempt_provider_key`; +- positive generation, canonical `sha256:<64 lowercase hex>` checks for every + identity/result/component hash, bounded identity/version fields, and bounded + canonical JSON size; +- a state-shape check: compilation-reserved/uncertain exclude accepted output; + `provider_result_accepted` requires canonical result plus all hashes and no + persisted compilation; `compilation_persisted` requires the same accepted + values plus `persisted_compilation_id`; `compilation_invalid_terminal` + excludes accepted/persisted + values and requires one bounded allowlisted failure code; +- a transition trigger permitting only the closed state graph and rejecting + identity, provider-key, accepted result/hash, timestamp, and terminal-state + mutation outside its one legal transition; +- delete/truncate guards so a consumed generation cannot disappear. + +`project_guide_compilations` requires: + +- unique one compilation per `attempt_id`, partial unique one root per + `(project_id, guide_id)` where predecessor is null, and unique one child per + `supersedes_compilation_id`; +- a scoped predecessor FK that cannot cross project/guide, exact attempt and + persisted-compilation linkage, positive strictly increasing setup generation, + canonical hash checks, and fixed service/action custody values; +- the canonical authorization resource-context digest, equal to the digest on + the exact allowed audit event; the digest itself binds the exact identity + link and final persist facts; +- update/delete/truncate guards: compilation rows are insert-only. + +The database intentionally does not make `result_hash` globally unique: two +later exact generations may compile to the same canonical result. Identity is +the unique attempt; content integrity is enforced by hashes and revalidation. + +Downgrade from `0062_guide_compilation` is permitted only while both compilation +custody tables are empty and no `project.guide_compilation.execute` audit +evidence exists. Once any of that durable custody exists, the migration fails +closed: operators must recover forward or use a separately reviewed retention +and destructive-cleanup plan rather than silently deleting governed evidence. ## Allowed files -Project compilation models, schemas, validator, repository, composition seam, -one then-current Alembic migration, focused tests, and WS-POL-003 docs. +```text +backend/app/modules/authorization/api/__init__.py +backend/app/modules/authorization/api/project_guide_compilation.py +backend/app/modules/projects/guide_compilation/__init__.py +backend/app/modules/projects/guide_compilation/authorization.py +backend/app/modules/projects/guide_compilation/contracts.py +backend/app/modules/projects/guide_compilation/models.py +backend/app/modules/projects/guide_compilation/repository.py +backend/app/modules/projects/guide_compilation/validation.py +backend/app/db/models.py # metadata discovery only +backend/alembic/versions/0062_project_guide_compilation_foundation.py +backend/tests/architecture/test_authorization_boundary.py +backend/tests/projects/guide_compilation/** +backend/tests/test_alembic.py +backend/tests/conftest.py +.ci/behavior-ownership/partition.v1.json +.ci/behavior-ownership/auth/** +.ci/behavior-ownership/lifecycle/** +backend/scripts/behavior_ownership.py # exact POL-03A additions only +backend/tests/test_behavior_ownership.py # exact transition proof only +backend/scripts/test_structure_boundary.py # add exact POL-03A scope only +backend/tests/architecture/test_test_structure_boundary.py +.github/workflows/backend.yml # exact POL-03A 90% coverage gate only +docker-compose.yml # pin local Postgres to CI digest +backend/scripts/run_test_lanes.py # assign exact POL-03A focused tests +backend/tests/test_ci_test_lanes.py # exact lane-inventory proof only +.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/IMPORT_LEDGER.md +.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json +.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/assertion-maps/** +.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/STATUS.md +.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/** +``` + +Changes to `tests/conftest.py` are limited to generic database plumbing. All +compilation fixtures and behavior assertions live under the new focused test +package. Changes to `test_alembic.py` are limited to exact migration +registration, topology, and the historical round-trip assertion accounting +for the new independently tested action-evidence token. The behavior-ownership +transition accepts only the exact new callable-bearing eligible files named by +this contract and rejects removal, +reassignment, extra, or reordered custody. The declarative model module remains +explicitly unresolved because the catalogue correctly rejects executable +SQLAlchemy metadata as structural-only. The structure validator adds the exact new package/tests +to its scope with zero permitted new debt. ## Not allowed -Action activation, provider calls, Celery cutover, policy projections, -approval, checker execution, compatibility paths, or ART changes. +- Changes to broad `projects/models.py`, `projects/repository.py`, + `interfaces/project_agents.py`, or existing project services/routes. +- Model/provider calls, Celery/setup cutover, live composition, or endpoint + changes. +- AUTH runtime/evaluator/catalogue/action availability/service-matrix changes; + the exact planned execute pair in the audit-evidence constraint is the sole + vocabulary-only exception and grants no runtime authority. +- Policy projection, approval, checker execution, guide activation, or ART, + task, submission, review, revision, contribution, or compensation behavior. +- Compatibility aliases, fallbacks, dual persistence paths, mutable-current + flags, or prepared handles in rows/messages. +- Raw guide duplication, provider credentials/reasoning, or unbounded failure + details in durable rows. +- New or increased private AUTH import debt or structural test debt. +- Wholesale cherry-pick/replay of WIP `1a7242f2`; only reviewed DDL, logic, and + assertions may be selectively adapted into the new modular/test shape. + +## Acceptance criteria + +- Database and ORM enforce one attempt per exact setup run/generation, one + provider key, complete identity/hash presence, and legal state/result shapes. +- Concurrent identical reservation returns one attempt; mismatched identity for + the generation denies without inventing another provider key. +- Uncertainty reconciles only under the original provider key; unsafe output is + bounded, terminal, and non-retryable for that generation. +- Accepted canonical JSON revalidates through the merged strict compilation + result/context contracts and matches full/component hashes before insertion. +- Crash recovery from accepted-but-not-persisted inserts or returns exactly one + immutable compilation without provider dispatch or policy projection. +- Append-only CAS permits one root and one child for an expected predecessor; + stale/concurrent forks fail closed. Update/delete/truncate and identity/key + mutation are rejected by Postgres. +- Every compilation references an exact allowed authority event for its own + attempt, project, actor, fixed service, permission, and execute action; + unrelated, denied, or borrowed audit evidence is rejected by Postgres. +- Request and execute seams deny before repository mutation. Only AUTH public + API imports are introduced; the private-import ledger count does not grow. +- No transaction/lock spans provider I/O because 03A performs none and exposes + only caller-owned short repository operations. +- Migration upgrade/downgrade, one-head topology, ORM parity, concurrency, + rollback, immutability, and crash states are proven. +- New/changed subsystem coverage is at least 90%; hosted repository coverage + remains at least 78%. +- Each new test owns one primary observable behavior; no new test or production + function exceeds the structural policy limits. The extended structure gate + inventories the exact new package/tests and permits zero new debt; test-delta + review maps every new test to one named primary invariant. +- Recovery returns exactly one closed classification—`compilation_reserved`, + `compilation_provider_uncertain`, `provider_result_accepted_not_persisted`, + `compilation_persisted`, or `compilation_invalid_terminal`—with bounded + operator-safe reason codes. It never infers + setup success, effective policy, guide activation, contribution, + compensation, or reputation effects. +- Deny/attack proof uses an instrumented repository/session and covers PM + execute, fixed-service request, wrong actor/link/service, stale generation, + cross-project/guide/source replay, request-vs-execute misuse, copied handle + values, result/component swaps, predecessor mismatch, unsafe text, and + attempted raw provider text/secrets/paths/URLs/unbounded failure persistence. +- Concurrent identical reservation returns the same attempt/key; mismatched + identity creates no key; uncertainty accepts only the original key; + accepted-not-persisted recovery creates/returns one compilation; and terminal + invalid state blocks retry for that generation. + +## Verification commands + +```bash +cd backend +uv run ruff check app/modules/authorization/api app/modules/projects/guide_compilation \ + app/db/models.py scripts/behavior_ownership.py scripts/test_structure_boundary.py \ + tests/projects/guide_compilation tests/test_alembic.py tests/conftest.py \ + tests/test_behavior_ownership.py tests/architecture/test_test_structure_boundary.py +uv run pytest -q tests/projects/guide_compilation \ + tests/architecture/test_authorization_boundary.py +uv run pytest -q tests/test_behavior_ownership.py \ + tests/architecture/test_test_structure_boundary.py +uv run pytest -q tests/projects/guide_compilation \ + --cov=app.modules.projects.guide_compilation --cov-fail-under=90 +uv run pytest -q tests/test_alembic.py +uv run python -m scripts.authorization_boundary validate \ + --ledger ../.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/IMPORT_LEDGER.md +uv run python -m scripts.test_structure_boundary validate \ + --policy ../.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_POLICY.md \ + --ledger ../.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json +uv run python -m scripts.behavior_ownership validate +cd .. +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_markdown_links.py +git diff --check origin/main +``` + +Full backend tests and repository coverage run only in GitHub Actions. -## Acceptance +## Required reviewers -- One durable attempt is uniquely bound to canonical input hash, project, - guide/source, both catalogue snapshots, setup run/generation, agent identity, - and instruction version; compare-and-swap selects one current compilation. -- States distinguish reserved, provider-uncertain, accepted, invalid-terminal, - persisted, and superseded. Invalid/unsafe output terminally consumes the - generation; transport uncertainty alone is reconciled under the same key. -- Reservation commits before provider I/O. No transaction/lock spans I/O. -- Strict validation, safe text, evidence grammar, component hashes, and - append-only invariants are proven while every runtime seam denies. +- Architecture: public AUTH purity, Projects modularity, append-only graph, and + absence of a competing protocol/path. +- Security/authorization: exact facts, opaque capability custody, deny ordering, + safe durable fields, replay/uncertainty, and no private AUTH edge. +- QA: Postgres concurrency, crash transitions, stale CAS, rollback, + immutability, and no-live-behavior proof. +- Product/operations: setup-generation consumption and terminal recovery without + changing Workstream lifecycle truth. +- Senior engineering, test-delta, CI-integrity, docs, and reuse/dedup. -## Verification and review +## Human review focus -PostgreSQL migration/uniqueness/CAS/crash-state tests, validator/security tests, -Ruff, hosted CI, and all L1 reviewer tracks. Human focus: durable cardinality -and fail-closed hidden state. +- Can one setup generation ever cause a second logical provider attempt/key? +- Can accepted output survive a crash without becoming effective policy? +- Can append-only supersession fork under concurrency? +- Is the new exact AUTH surface dependency-free, opaque, and inactive? +- Did 03A remain hidden with zero live product behavior? diff --git a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-03A-external-review-response.md b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-03A-external-review-response.md new file mode 100644 index 000000000..2972e56ac --- /dev/null +++ b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-03A-external-review-response.md @@ -0,0 +1,73 @@ +# WS-POL-003-03A External Review Response + +## Comments addressed + +- All five substantive CodeRabbit threads were validated and corrected: + compilation attempt states are subsystem-specific; repository failures expose + one domain hierarchy with a retryable lineage-race subtype; the canonical + execute digest has explicit behavior ownership; deny-only coroutines are + created inside their assertion scopes; and local PostgreSQL uses CI's exact + image digest. +- Valid review-body findings were also corrected: explicit public-fact test + construction, canonical-result equality, shared component-hash constraint + composition, full-result-hash coverage, fresh ORM reloads, fixed-service + narrowing, migration cleanup safety and schema-scoped trigger inspection, + and application/database terminal-code parity. +- Initial exact-head GitHub backend lanes all stopped at the shared docstring + gate before test execution. The nine new repository callables and six + deny-only authorization methods now document their exact responsibilities; + repository-wide docstring coverage is 80.4 percent, above the unchanged 80 + percent docstring gate. This is distinct from the 78 percent repository test + coverage floor. +- On the corrected head, four semantic lanes passed. `schema_contracts_a` + exposed a stale 0049 round-trip assertion: it stripped only 0049 action + tokens before comparing the current head to 0048. The assertion now also + strips 0062's exact, independently tested compilation action token. Migration + behavior and database guards are unchanged. +- The first assertion-fix head was rejected by the zero-growth preflight because + it expanded the already oversized historical migration test. The correction + was reformatted to shrink that file from 14,096 to 14,093 lines; the debt + ledger records only that exact shrink and new content hash. CI-integrity + re-review and canonical structure validation pass with no exception or new + debt. + +## Comments deferred + +- The suggested `NOT VALID` rewrite for the three `audit_events` constraints was + not applied. Migration 0062 deliberately locks and rewrites all three closed + registries in one transaction so no observer can see a partial catalogue. + PostgreSQL retains the `ACCESS EXCLUSIVE` lock until that transaction commits, + so adding and validating `NOT VALID` constraints inside the same transaction + would not shorten the lock and would weaken the single atomic registry change. +- Test-only fixture consolidation suggestions are recorded as non-functional + cleanup, not mixed into this security correction; every affected test retains + explicit engine cleanup and focused behavior ownership. +- The request to express internal engineering reviewer results as + `accept`/`needs_revision`/`reject` was not applied. Those values are reserved + for Workstream product review decisions; engineering review evidence remains + pass/fail with residual risk recorded separately, as required by `AGENTS.md`. + +## Human decisions needed + +- None for this correction. Human merge approval remains required after all + exact-head checks and external review complete. + +## Commands rerun + +- Scoped Ruff for both corrected modules. +- Repository `docstr-coverage --config .docstr.yaml`. +- Test-structure validation and diff integrity. +- Focused 0062 migration-contract tests: 2 passed against PostgreSQL. +- Final isolated guide-compilation suite: 31 passed with 93.08 percent package + coverage against the unchanged 90 percent floor. +- Structure, behavior-ownership, and lane-inventory regression bundle: 161 + passed. +- Test-delta re-review: pass; the historical assertion still requires both the + 0049 and 0062 additions exactly twice and rejects all other definition drift. +- Hosted schema lane on the corrected head. + +## Remaining risks + +- GitHub must rerun every backend lane and coverage gate on the corrected head. +- CodeRabbit must receive the corrective head and every resolved thread must be + verified through the thread-aware API before merge readiness. diff --git a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-03A-internal-review-evidence.md b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-03A-internal-review-evidence.md new file mode 100644 index 000000000..cbb723ce4 --- /dev/null +++ b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-03A-internal-review-evidence.md @@ -0,0 +1,70 @@ +# WS-POL-003-03A Internal Review Evidence + +Date: 2026-08-10. Risk: L1. + +## Deterministic evidence + +- Real-PostgreSQL focused compilation suite: 31 passed after external-review + corrections. +- New guide-compilation subsystem coverage: 93.08 percent, above the required + 90 percent floor. +- Focused AUTH boundary, structure, behavior-ownership, and public contract + proof: passed; reviewer run recorded 184 passing tests. +- Structure-boundary regression suite: 25 passed after explicitly placing + every POL-03A production and test file inside the zero-growth/skip gate. +- Hosted-style semantic-lane collection: 3,764 tests collected and exact lane + evidence validated; lane inventory regression suite: 34 passed. +- Empty upgrade/downgrade, non-empty downgrade refusal, real trigger, + concurrency, replay, immutability, and crash-recovery tests passed. +- Scoped Ruff, authorization boundary, test structure, behavior ownership, + stale wording, Markdown links, and diff integrity checks passed. +- GitHub now preserves the repository-wide 78 percent gate and adds an exact + 90 percent guide-compilation subsystem gate. No workflow failure policy or + threshold was weakened. + +## Review results + +- Architecture: pass; the new Projects package depends only on the + dependency-free AUTH public API and creates no competing protocol or live + cross-module path. +- Security/authorization: pass after exact canonical resource-context digest, + fixed service profile/link, action, permission, project, attempt, and audit + evidence binding were enforced at the database boundary. +- QA: pass; exact attempt identity, crash replay, append-only root/child CAS, + concurrent fork prevention, stale predecessor denial, state shapes, guarded + downgrade, and hidden deny-only behavior are covered. +- Product/operations: pass; `provider_result_accepted` remains provider-result custody only and + creates no approval, activation, review, payment, contribution, or reputation + truth. +- Senior engineering: pass; package files remain below structural limits and + the unsafe evidence-fixture default was removed. +- Test delta: pass; tests are additive, behavior-focused, and contain no skip, + xfail, or weakened assertion path. +- CI integrity: pass after all focused tests entered the semantic-lane and + structure inventories and the hosted 90 percent coverage gate was added. +- Docs: pass after documenting that downgrade is empty-only and otherwise + requires forward recovery or separately reviewed destructive cleanup. +- Reuse/dedup: pass; keeping the public digest implementation dependency-light + and the fixed service literal inside the boundary avoids introducing a + private cross-module dependency. + +Fresh corrective reviews after the CodeRabbit findings: + +- Architecture: pass with low risks; the suggested distinction for unexpected + storage failures was implemented as `GuideCompilationStorageError` while one + repository-domain base remains catchable. +- Security: pass; state, authority evidence, lineage, migration, and deny-only + behavior remain fail closed. +- QA: pass with low risks; the required PostgreSQL suite subsequently passed + locally against a runner-owned isolated database. +- Test delta: initial fail was corrected, then pass with low risks after live + migrated-vocabulary proof, deterministic DB-error classification, and + trigger-cause plus durable-state assertions were added. +- CI integrity: pass with low risks; no threshold, failure policy, lane, lint, + or test gate was weakened, and local PostgreSQL now matches CI's exact image. +- Senior engineering: pass with low risks; its live-migration parity concern is + covered by the new `pg_get_constraintdef` and direct rejection test. + +All valid findings were corrected and re-reviewed. No internal reviewer session +remains open; external CodeRabbit thread verification is tracked separately in +the external review response. diff --git a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-03A-pr-trust-bundle.md b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-03A-pr-trust-bundle.md new file mode 100644 index 000000000..8c77e6f54 --- /dev/null +++ b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-03A-pr-trust-bundle.md @@ -0,0 +1,57 @@ +# WS-POL-003-03A PR Trust Bundle + +## Goal and design + +Install hidden, immutable custody for one logical unified project-guide +compilation attempt per exact setup generation. Reservation persists before any +future provider I/O, uncertainty retains the same provider key, accepted output +survives a crash before projection, and append-only predecessor CAS prevents a +current-compilation fork. + +The new Projects package is isolated from broad legacy project services. Its +only authorization dependency is a dependency-free public AUTH facts/port +module. The project-side adapter denies every operation in this chunk; no +action is activated and no route, worker, provider call, policy projection, or +live product behavior is added. + +## Protected custody + +- Exact attempt identity binds project, guide, source, setup generation, + canonical inputs, catalogue snapshots, agent/instruction versions, operation, + request, and one derived provider idempotency key. +- Accepted output is canonical, bounded, and revalidated against complete and + component hashes before immutable persistence. +- A compilation requires matching allowed audit evidence for the exact actor, + active fixed service profile/link, execute action, permission, attempt, + project, and canonical resource-context digest. +- Database triggers reject illegal state transitions, mutation, deletion, + truncation, stale predecessor use, concurrent forks, unrelated evidence, and + non-empty downgrade. + +## Scope and non-goals + +The change adds migration 0062, focused ORM/contracts/repository/validation, +the public deny-only AUTH seam, behavior ownership records, structural and lane +registration, and tests. It does not call the compilation agent, enable AUTH +runtime actions, start Celery work, expose an API, approve or activate policy, +or alter submission/checker/review/contribution/compensation behavior. + +## Evidence and reviews + +- 26 focused PostgreSQL tests passed with 93.83 percent subsystem coverage. +- Boundary, behavior ownership, test structure, stale wording, links, Ruff, and + diff checks passed. +- The unchanged repository docstring gate passes after documenting every new + repository and deny-only authorization callable. +- Hosted-style inventory collected 3,764 tests with exact lane evidence; the + new package has an additive hosted 90 percent coverage gate. +- Architecture, security, QA, product/operations, senior engineering, + test-delta, CI-integrity, docs, and reuse/dedup reviews passed after fixes. + +## Remaining risk and human focus + +Full repository coverage and all backend lanes must pass in GitHub Actions. +Human review should focus on whether any setup generation can acquire a second +provider key, whether accepted output can become policy accidentally, whether +append-only supersession can fork, whether audit evidence is exactly bound, +and whether the new authorization surface remains inactive and dependency-safe. diff --git a/.ci/behavior-ownership/auth/project-guide-compilation-facts.json b/.ci/behavior-ownership/auth/project-guide-compilation-facts.json new file mode 100644 index 000000000..e1ff9fdb5 --- /dev/null +++ b/.ci/behavior-ownership/auth/project-guide-compilation-facts.json @@ -0,0 +1,24 @@ +{ + "behavior_id": "auth.project_guide_compilation.facts", + "boundaries": [], + "callables": [ + "app.modules.authorization.api.project_guide_compilation.ProjectGuideCompilationAuthorizationPort.authorize_execute_preflight", + "app.modules.authorization.api.project_guide_compilation.ProjectGuideCompilationAuthorizationPort.consume_execute_persist", + "app.modules.authorization.api.project_guide_compilation.ProjectGuideCompilationAuthorizationPort.consume_request", + "app.modules.authorization.api.project_guide_compilation.ProjectGuideCompilationAuthorizationPort.prepare_execute_persist", + "app.modules.authorization.api.project_guide_compilation.ProjectGuideCompilationAuthorizationPort.prepare_request", + "app.modules.authorization.api.project_guide_compilation.ProjectGuideCompilationRequestFacts.__post_init__", + "app.modules.authorization.api.project_guide_compilation._validate_common", + "app.modules.authorization.api.project_guide_compilation.project_guide_compilation_execute_resource_digest" + ], + "group": "auth", + "outcomes": ["return", "mapped_error"], + "reviewed_by": ["WS-POL-003-03A required reviewers"], + "schema": "workstream.behavior-ownership.v1", + "status": "reviewed", + "target": "backend/app/modules/authorization/api/project_guide_compilation.py", + "tests": [ + "backend/tests/projects/guide_compilation/test_public_authorization.py::test_public_facts_reject_wrong_uuid_and_unbounded_token", + "backend/tests/projects/guide_compilation/test_repository_persistence.py::test_wrong_resource_authority_leaves_accepted_attempt_unpersisted" + ] +} diff --git a/.ci/behavior-ownership/lifecycle/project-guide-compilation-authorization.json b/.ci/behavior-ownership/lifecycle/project-guide-compilation-authorization.json new file mode 100644 index 000000000..40995c01a --- /dev/null +++ b/.ci/behavior-ownership/lifecycle/project-guide-compilation-authorization.json @@ -0,0 +1,21 @@ +{ + "behavior_id": "lifecycle.project_guide_compilation.authorization", + "boundaries": [], + "callables": [ + "app.modules.projects.guide_compilation.authorization.DenyProjectGuideCompilationAuthorization._deny", + "app.modules.projects.guide_compilation.authorization.DenyProjectGuideCompilationAuthorization.authorize_execute_preflight", + "app.modules.projects.guide_compilation.authorization.DenyProjectGuideCompilationAuthorization.consume_execute_persist", + "app.modules.projects.guide_compilation.authorization.DenyProjectGuideCompilationAuthorization.consume_request", + "app.modules.projects.guide_compilation.authorization.DenyProjectGuideCompilationAuthorization.prepare_execute_persist", + "app.modules.projects.guide_compilation.authorization.DenyProjectGuideCompilationAuthorization.prepare_request" + ], + "group": "lifecycle", + "outcomes": ["denial"], + "reviewed_by": ["WS-POL-003-03A required reviewers"], + "schema": "workstream.behavior-ownership.v1", + "status": "reviewed", + "target": "backend/app/modules/projects/guide_compilation/authorization.py", + "tests": [ + "backend/tests/projects/guide_compilation/test_public_authorization.py::test_hidden_authorization_denies_before_touching_product_state" + ] +} diff --git a/.ci/behavior-ownership/lifecycle/project-guide-compilation-contracts.json b/.ci/behavior-ownership/lifecycle/project-guide-compilation-contracts.json new file mode 100644 index 000000000..cf68613fe --- /dev/null +++ b/.ci/behavior-ownership/lifecycle/project-guide-compilation-contracts.json @@ -0,0 +1,22 @@ +{ + "behavior_id": "lifecycle.project_guide_compilation.contracts", + "boundaries": [], + "callables": [ + "app.modules.projects.guide_compilation.contracts.AcceptedCompilationResult.validate_hashes", + "app.modules.projects.guide_compilation.contracts.CompilationAttemptIdentity.from_context", + "app.modules.projects.guide_compilation.contracts.CompilationAttemptIdentity.provider_idempotency_key", + "app.modules.projects.guide_compilation.contracts.accepted_compilation_result", + "app.modules.projects.guide_compilation.contracts.validate_accepted_compilation_result" + ], + "group": "lifecycle", + "outcomes": ["return", "mapped_error"], + "reviewed_by": ["WS-POL-003-03A required reviewers"], + "schema": "workstream.behavior-ownership.v1", + "status": "reviewed", + "target": "backend/app/modules/projects/guide_compilation/contracts.py", + "tests": [ + "backend/tests/projects/guide_compilation/test_contracts.py::test_attempt_provider_key_is_deterministic_and_context_bound", + "backend/tests/projects/guide_compilation/test_contracts.py::test_accepted_result_rejects_component_or_full_hash_drift", + "backend/tests/projects/guide_compilation/test_contracts.py::test_accepted_result_revalidates_against_fresh_context" + ] +} diff --git a/.ci/behavior-ownership/lifecycle/project-guide-compilation-repository.json b/.ci/behavior-ownership/lifecycle/project-guide-compilation-repository.json new file mode 100644 index 000000000..9babb1d0c --- /dev/null +++ b/.ci/behavior-ownership/lifecycle/project-guide-compilation-repository.json @@ -0,0 +1,35 @@ +{ + "behavior_id": "lifecycle.project_guide_compilation.repository", + "boundaries": ["postgresql", "trigger", "concurrency"], + "callables": [ + "app.modules.projects.guide_compilation.repository.GuideCompilationRepository.__init__", + "app.modules.projects.guide_compilation.repository.GuideCompilationRepository._compilation_for_attempt", + "app.modules.projects.guide_compilation.repository.GuideCompilationRepository._current", + "app.modules.projects.guide_compilation.repository.GuideCompilationRepository._lock_attempt", + "app.modules.projects.guide_compilation.repository.GuideCompilationRepository._required_attempt", + "app.modules.projects.guide_compilation.repository.GuideCompilationRepository._transition", + "app.modules.projects.guide_compilation.repository.GuideCompilationRepository.accept_result", + "app.modules.projects.guide_compilation.repository.GuideCompilationRepository.mark_invalid_terminal", + "app.modules.projects.guide_compilation.repository.GuideCompilationRepository.mark_provider_uncertain", + "app.modules.projects.guide_compilation.repository.GuideCompilationRepository.persist_accepted", + "app.modules.projects.guide_compilation.repository.GuideCompilationRepository.recovery_classification", + "app.modules.projects.guide_compilation.repository.GuideCompilationRepository.reserve_attempt", + "app.modules.projects.guide_compilation.repository._compilation_identity_values", + "app.modules.projects.guide_compilation.repository._identity_values", + "app.modules.projects.guide_compilation.repository._matches" + ], + "group": "lifecycle", + "outcomes": ["persisted_state", "idempotent_replay", "recovery_outcome", "mapped_error"], + "reviewed_by": ["WS-POL-003-03A required reviewers"], + "schema": "workstream.behavior-ownership.v1", + "status": "reviewed", + "target": "backend/app/modules/projects/guide_compilation/repository.py", + "tests": [ + "backend/tests/projects/guide_compilation/test_repository_attempts.py::test_concurrent_reservation_converges_on_one_key", + "backend/tests/projects/guide_compilation/test_repository_attempts.py::test_reservation_identity_mismatch_reuses_no_key", + "backend/tests/projects/guide_compilation/test_repository_attempts.py::test_uncertain_to_invalid_terminal_preserves_one_attempt", + "backend/tests/projects/guide_compilation/test_repository_persistence.py::test_accepted_crash_recovery_persists_exactly_once", + "backend/tests/projects/guide_compilation/test_repository_persistence.py::test_stale_predecessor_fails_closed", + "backend/tests/projects/guide_compilation/test_repository_persistence.py::test_compilation_update_is_rejected" + ] +} diff --git a/.ci/behavior-ownership/lifecycle/project-guide-compilation-validation.json b/.ci/behavior-ownership/lifecycle/project-guide-compilation-validation.json new file mode 100644 index 000000000..22c8adce5 --- /dev/null +++ b/.ci/behavior-ownership/lifecycle/project-guide-compilation-validation.json @@ -0,0 +1,20 @@ +{ + "behavior_id": "lifecycle.project_guide_compilation.validation", + "boundaries": [], + "callables": [ + "app.modules.projects.guide_compilation.validation.accepted_from_attempt", + "app.modules.projects.guide_compilation.validation.identity_from_attempt", + "app.modules.projects.guide_compilation.validation.validate_persistence_authority", + "app.modules.projects.guide_compilation.validation.validate_terminal_failure_code" + ], + "group": "lifecycle", + "outcomes": ["return", "denial", "mapped_error"], + "reviewed_by": ["WS-POL-003-03A required reviewers"], + "schema": "workstream.behavior-ownership.v1", + "status": "reviewed", + "target": "backend/app/modules/projects/guide_compilation/validation.py", + "tests": [ + "backend/tests/projects/guide_compilation/test_repository_persistence.py::test_accepted_crash_recovery_persists_exactly_once", + "backend/tests/projects/guide_compilation/test_repository_persistence.py::test_stale_predecessor_fails_closed" + ] +} diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 781602c5c..adba0afe3 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -340,6 +340,10 @@ "group": "auth", "target": "backend/app/modules/authorization/api/ports.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/api/project_guide_compilation.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/catalogue.py" @@ -516,6 +520,26 @@ "group": "lifecycle", "target": "backend/app/modules/projects/create_service.py" }, + { + "group": "lifecycle", + "target": "backend/app/modules/projects/guide_compilation/authorization.py" + }, + { + "group": "lifecycle", + "target": "backend/app/modules/projects/guide_compilation/contracts.py" + }, + { + "group": "lifecycle", + "target": "backend/app/modules/projects/guide_compilation/models.py" + }, + { + "group": "lifecycle", + "target": "backend/app/modules/projects/guide_compilation/repository.py" + }, + { + "group": "lifecycle", + "target": "backend/app/modules/projects/guide_compilation/validation.py" + }, { "group": "lifecycle", "target": "backend/app/modules/projects/guide_mutation_repository.py" @@ -733,7 +757,7 @@ "target": "backend/scripts/week2_api_e2e.py" } ], - "authority_digest": "36f63f2cb637dc92f32cacc37d69fe1dd05c99ae309ea8647441ee8e2bdd9efc", + "authority_digest": "225faaad98e3c22d6306b240aa933e5a58e879450d0f9ea67497ab698e5ecda7", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index afed70dbb..834df7981 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -448,6 +448,14 @@ jobs: working-directory: backend run: coverage report --include='app/modules/authorization/*' --precision=2 --fail-under=90 + - name: Project guide compilation subsystem coverage + working-directory: backend + run: >- + coverage report + --include='app/modules/projects/guide_compilation/*' + --precision=2 + --fail-under=90 + - name: Project creation cutover per-file coverage working-directory: backend shell: bash diff --git a/backend/alembic/versions/0062_project_guide_compilation_foundation.py b/backend/alembic/versions/0062_project_guide_compilation_foundation.py new file mode 100644 index 000000000..371063645 --- /dev/null +++ b/backend/alembic/versions/0062_project_guide_compilation_foundation.py @@ -0,0 +1,484 @@ +"""Install hidden unified guide-compilation persistence. + +Revision ID: 0062_guide_compilation +Revises: 0061_submission_admission +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "0062_guide_compilation" +down_revision = "0061_submission_admission" +branch_labels = depends_on = None + +_HASH = r"^sha256:[0-9a-f]{64}$" +_ACTION = "project.guide_compilation.execute" +_PERMISSION = "project.guide_compilation.execute" +_RESOURCE_MARKER = ( + "('project_submission_artifact_policy_mutation'::character varying)::text" +) +_RESOURCE_ADDITION = ( + ", ('project_guide_compilation_attempt'::character varying)::text" +) +_PERMISSION_MARKER = "('review.queue.override'::character varying)::text" +_PERMISSION_ADDITION = ( + ", ('project.guide_compilation.execute'::character varying)::text" +) + + +def _rewrite_permission_registry(*, add: bool) -> None: + connection = op.get_bind() + definition = connection.execute( + sa.text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authority_registries'" + ) + ).scalar_one() + expanded = _PERMISSION_MARKER + _PERMISSION_ADDITION + source, target = ( + (_PERMISSION_MARKER, expanded) + if add + else (expanded, _PERMISSION_MARKER) + ) + if definition.count(source) != 1 or (add and expanded in definition): + raise RuntimeError("unexpected compilation permission registry") + op.drop_constraint("authority_registries", "audit_events", type_="check") + op.execute( + "alter table audit_events add constraint " + f"ck_audit_events_authority_registries {definition.replace(source, target, 1)}" + ) + + +def _rewrite_audit_resource(*, add: bool) -> None: + connection = op.get_bind() + definition = connection.execute( + sa.text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authority_privacy_bounds'" + ) + ).scalar_one() + expanded = _RESOURCE_MARKER + _RESOURCE_ADDITION + source, target = ( + (_RESOURCE_MARKER, expanded) + if add + else (expanded, _RESOURCE_MARKER) + ) + if definition.count(source) != 1 or (add and expanded in definition): + raise RuntimeError("unexpected compilation audit-resource registry") + op.drop_constraint("authority_privacy_bounds", "audit_events", type_="check") + op.execute( + "alter table audit_events add constraint " + f"ck_audit_events_authority_privacy_bounds {definition.replace(source, target, 1)}" + ) + + +def _action_pair_token() -> str: + return ( + f"(((action_id)::text = '{_ACTION}'::text) AND " + f"((permission_id)::text = '{_PERMISSION}'::text))" + ) + + +def _rewrite_action_evidence(*, add: bool) -> None: + connection = op.get_bind() + definition = connection.execute( + sa.text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authorization_action_evidence'" + ) + ).scalar_one() + marker = ( + "(((action_id)::text = 'project.guide_sufficiency.run'::text) AND " + "((permission_id)::text = 'project.guide.manage'::text))" + ) + addition = " OR " + _action_pair_token() + if add: + if definition.count(marker) != 2 or _action_pair_token() in definition: + raise RuntimeError("unexpected compilation action-evidence registry") + definition = definition.replace(marker, marker + addition) + else: + if definition.count(addition) != 2: + raise RuntimeError("unexpected compilation action-evidence registry") + definition = definition.replace(addition, "") + op.drop_constraint("authorization_action_evidence", "audit_events", type_="check") + op.execute( + "alter table audit_events add constraint " + f"ck_audit_events_authorization_action_evidence {definition}" + ) + + +def upgrade() -> None: + """Create the attempt fence and append-only compilation graph.""" + op.execute("lock table audit_events in access exclusive mode") + _rewrite_audit_resource(add=True) + _rewrite_permission_registry(add=True) + _rewrite_action_evidence(add=True) + op.create_table( + "project_guide_compilation_attempts", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("project_id", sa.String(36), 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_hash", sa.String(71), nullable=False), + sa.Column("setup_run_id", sa.String(36), nullable=False), + sa.Column("setup_generation", sa.BigInteger(), nullable=False), + sa.Column("canonical_input_hash", sa.String(71), nullable=False), + sa.Column("guide_material_hash", sa.String(71), nullable=False), + sa.Column("pre_catalogue_id", sa.String(160), nullable=False), + sa.Column("pre_catalogue_version", sa.String(100), nullable=False), + sa.Column("pre_catalogue_schema_version", sa.String(160), nullable=False), + sa.Column("pre_catalogue_manifest_hash", sa.String(71), nullable=False), + sa.Column("post_catalogue_id", sa.String(160), nullable=False), + sa.Column("post_catalogue_version", sa.String(100), nullable=False), + sa.Column("post_catalogue_schema_version", sa.String(160), nullable=False), + sa.Column("post_catalogue_manifest_hash", sa.String(71), nullable=False), + sa.Column("agent_identity", sa.String(100), nullable=False), + sa.Column("agent_version", sa.String(100), nullable=False), + sa.Column("instruction_version", sa.String(100), nullable=False), + sa.Column("provider_idempotency_key", sa.Uuid(), nullable=False), + sa.Column("status", sa.String(32), nullable=False), + sa.Column("canonical_result", sa.JSON()), + sa.Column("result_hash", sa.String(71)), + sa.Column("component_hashes", sa.JSON()), + sa.Column("failure_code", sa.String(100)), + sa.Column("persisted_compilation_id", sa.Uuid()), + sa.Column("reserved_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("provider_uncertain_at", sa.DateTime(timezone=True)), + sa.Column("accepted_at", sa.DateTime(timezone=True)), + sa.Column("terminal_at", sa.DateTime(timezone=True)), + sa.Column("persisted_at", sa.DateTime(timezone=True)), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"]), + sa.ForeignKeyConstraint(["guide_id"], ["project_guides.id"]), + sa.ForeignKeyConstraint( + ["source_snapshot_id", "source_snapshot_hash"], + ["guide_source_snapshots.id", "guide_source_snapshots.bundle_hash"], + name="fk_compilation_attempt_snapshot_hash", + ), + sa.ForeignKeyConstraint( + ["setup_run_id", "project_id", "guide_id", "source_snapshot_id", "setup_generation"], + ["project_setup_runs.id", "project_setup_runs.project_id", "project_setup_runs.guide_id", "project_setup_runs.source_snapshot_id", "project_setup_runs.setup_generation"], + name="fk_compilation_attempt_exact_setup", + ), + sa.UniqueConstraint("setup_run_id", "setup_generation", name="uq_compilation_attempt_setup_generation"), + sa.UniqueConstraint("provider_idempotency_key", name="uq_compilation_attempt_provider_key"), + sa.CheckConstraint("setup_generation > 0", name="ck_compilation_attempt_generation"), + sa.CheckConstraint( + "status in ('compilation_reserved','compilation_provider_uncertain','provider_result_accepted','compilation_invalid_terminal','compilation_persisted')", + name="ck_compilation_attempt_status", + ), + sa.CheckConstraint( + f"source_snapshot_hash ~ '{_HASH}' and canonical_input_hash ~ '{_HASH}' and guide_material_hash ~ '{_HASH}' and " + f"pre_catalogue_manifest_hash ~ '{_HASH}' and post_catalogue_manifest_hash ~ '{_HASH}'", + name="ck_compilation_attempt_identity_hashes", + ), + sa.CheckConstraint(f"result_hash is null or result_hash ~ '{_HASH}'", name="ck_compilation_attempt_result_hash"), + sa.CheckConstraint("canonical_result is null or octet_length(canonical_result::text) <= 4194304", name="ck_compilation_attempt_result_size"), + sa.CheckConstraint( + "component_hashes is null or (json_typeof(component_hashes)='object' and " + "component_hashes::jsonb=jsonb_build_object(" + "'sufficiency_hash',component_hashes->>'sufficiency_hash'," + "'artifact_policy_hash',component_hashes->>'artifact_policy_hash'," + "'requirement_inventory_hash',component_hashes->>'requirement_inventory_hash'," + "'pre_submit_hash',component_hashes->>'pre_submit_hash'," + "'post_submit_hash',component_hashes->>'post_submit_hash'," + "'capability_suggestions_hash',component_hashes->>'capability_suggestions_hash'," + "'setup_notes_hash',component_hashes->>'setup_notes_hash') and " + + " and ".join( + f"coalesce((component_hashes->>'{name}') ~ '{_HASH}',false)" + for name in ( + "sufficiency_hash", "artifact_policy_hash", "requirement_inventory_hash", + "pre_submit_hash", "post_submit_hash", "capability_suggestions_hash", + "setup_notes_hash", + ) + ) + + ")", + name="ck_compilation_attempt_component_hashes", + ), + sa.CheckConstraint( + "(status='compilation_reserved' and provider_uncertain_at is null and accepted_at is null and terminal_at is null and persisted_at is null and canonical_result is null and result_hash is null and component_hashes is null and failure_code is null and persisted_compilation_id is null) or " + "(status='compilation_provider_uncertain' and provider_uncertain_at is not null and accepted_at is null and terminal_at is null and persisted_at is null and canonical_result is null and result_hash is null and component_hashes is null and failure_code is null and persisted_compilation_id is null) or " + "(status='provider_result_accepted' and accepted_at is not null and terminal_at is null and persisted_at is null and canonical_result is not null and result_hash is not null and component_hashes is not null and failure_code is null and persisted_compilation_id is null) or " + "(status='compilation_persisted' and accepted_at is not null and persisted_at is not null and terminal_at is null and canonical_result is not null and result_hash is not null and component_hashes is not null and failure_code is null and persisted_compilation_id is not null) or " + "(status='compilation_invalid_terminal' and terminal_at is not null and accepted_at is null and persisted_at is null and canonical_result is null and result_hash is null and component_hashes is null and persisted_compilation_id is null and failure_code in ('schema_invalid','unsafe_text','hash_mismatch','context_mismatch'))", + name="ck_compilation_attempt_state_shape", + ), + ) + for column in ("project_id", "guide_id", "source_snapshot_id", "setup_run_id"): + op.create_index(f"ix_project_guide_compilation_attempts_{column}", "project_guide_compilation_attempts", [column]) + + op.create_table( + "project_guide_compilations", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("attempt_id", sa.Uuid(), nullable=False), + sa.Column("project_id", sa.String(36), 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_hash", sa.String(71), nullable=False), + sa.Column("setup_run_id", sa.String(36), nullable=False), + sa.Column("setup_generation", sa.BigInteger(), nullable=False), + sa.Column("canonical_input_hash", sa.String(71), nullable=False), + sa.Column("guide_material_hash", sa.String(71), nullable=False), + sa.Column("pre_catalogue_manifest_hash", sa.String(71), nullable=False), + sa.Column("post_catalogue_manifest_hash", sa.String(71), nullable=False), + sa.Column("agent_identity", sa.String(100), nullable=False), + sa.Column("agent_version", sa.String(100), nullable=False), + sa.Column("instruction_version", sa.String(100), nullable=False), + sa.Column("canonical_result", sa.JSON(), nullable=False), + sa.Column("result_hash", sa.String(71), nullable=False), + sa.Column("component_hashes", sa.JSON(), nullable=False), + sa.Column("supersedes_compilation_id", sa.Uuid()), + sa.Column("created_by_actor_profile_id", sa.String(36), nullable=False), + sa.Column("created_via_identity_link_id", sa.String(36), nullable=False), + sa.Column("created_by_service_identity", sa.String(160), nullable=False), + sa.Column("creation_action_id", sa.String(160), nullable=False), + sa.Column("authorization_decision_event_id", sa.String(36), nullable=False), + sa.Column("authorization_resource_context_digest", sa.String(71), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(["attempt_id"], ["project_guide_compilation_attempts.id"]), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"]), + sa.ForeignKeyConstraint(["guide_id"], ["project_guides.id"]), + sa.ForeignKeyConstraint(["source_snapshot_id"], ["guide_source_snapshots.id"]), + sa.ForeignKeyConstraint(["setup_run_id"], ["project_setup_runs.id"]), + sa.ForeignKeyConstraint(["created_by_actor_profile_id"], ["actor_profiles.id"]), + sa.ForeignKeyConstraint(["created_via_identity_link_id"], ["actor_identity_links.id"]), + sa.ForeignKeyConstraint(["authorization_decision_event_id"], ["audit_events.id"]), + sa.UniqueConstraint("attempt_id", name="uq_project_guide_compilation_attempt"), + sa.UniqueConstraint("id", "attempt_id", name="uq_project_guide_compilation_id_attempt"), + sa.UniqueConstraint("supersedes_compilation_id", name="uq_project_guide_compilation_predecessor"), + sa.UniqueConstraint("id", "project_id", "guide_id", name="uq_project_guide_compilation_scope"), + sa.ForeignKeyConstraint( + ["supersedes_compilation_id", "project_id", "guide_id"], + ["project_guide_compilations.id", "project_guide_compilations.project_id", "project_guide_compilations.guide_id"], + name="fk_project_guide_compilation_predecessor", + ), + sa.CheckConstraint( + f"source_snapshot_hash ~ '{_HASH}' and canonical_input_hash ~ '{_HASH}' and guide_material_hash ~ '{_HASH}' and pre_catalogue_manifest_hash ~ '{_HASH}' and post_catalogue_manifest_hash ~ '{_HASH}' and result_hash ~ '{_HASH}'", + name="ck_project_guide_compilation_hashes", + ), + sa.CheckConstraint( + "octet_length(canonical_result::text) <= 4194304 and " + "json_typeof(component_hashes)='object' and " + "component_hashes::jsonb=jsonb_build_object(" + "'sufficiency_hash',component_hashes->>'sufficiency_hash'," + "'artifact_policy_hash',component_hashes->>'artifact_policy_hash'," + "'requirement_inventory_hash',component_hashes->>'requirement_inventory_hash'," + "'pre_submit_hash',component_hashes->>'pre_submit_hash'," + "'post_submit_hash',component_hashes->>'post_submit_hash'," + "'capability_suggestions_hash',component_hashes->>'capability_suggestions_hash'," + "'setup_notes_hash',component_hashes->>'setup_notes_hash') and " + + " and ".join( + f"coalesce((component_hashes->>'{name}') ~ '{_HASH}',false)" + for name in ( + "sufficiency_hash", "artifact_policy_hash", "requirement_inventory_hash", + "pre_submit_hash", "post_submit_hash", "capability_suggestions_hash", + "setup_notes_hash", + ) + ), + name="ck_project_guide_compilation_result_shape", + ), + sa.CheckConstraint( + "setup_generation > 0 and created_by_service_identity='workstream.project.setup' and creation_action_id='project.guide_compilation.execute'", + name="ck_project_guide_compilation_custody", + ), + sa.CheckConstraint( + f"authorization_resource_context_digest ~ '{_HASH}'", + name="ck_project_guide_compilation_authorization_digest", + ), + ) + op.create_index( + "uq_project_guide_compilation_root", + "project_guide_compilations", + ["project_id", "guide_id"], + unique=True, + postgresql_where=sa.text("supersedes_compilation_id is null"), + ) + for column in ("project_id", "guide_id", "source_snapshot_id", "setup_run_id"): + op.create_index(f"ix_project_guide_compilations_{column}", "project_guide_compilations", [column]) + op.create_foreign_key( + "fk_compilation_attempt_exact_persisted_compilation", + "project_guide_compilation_attempts", + "project_guide_compilations", + ["persisted_compilation_id", "id"], + ["id", "attempt_id"], + ) + _install_guards() + + +def _install_guards() -> None: + statements = ( + """ + create function guard_project_guide_compilation_attempt_update() + returns trigger language plpgsql as $$ + begin + if row(new.project_id,new.guide_id,new.guide_version,new.source_snapshot_id, + new.source_snapshot_hash,new.setup_run_id,new.setup_generation, + new.canonical_input_hash,new.guide_material_hash,new.pre_catalogue_id, + new.pre_catalogue_version,new.pre_catalogue_schema_version, + new.pre_catalogue_manifest_hash,new.post_catalogue_id,new.post_catalogue_version, + new.post_catalogue_schema_version,new.post_catalogue_manifest_hash, + new.agent_identity,new.agent_version,new.instruction_version, + new.provider_idempotency_key) + is distinct from row(old.project_id,old.guide_id,old.guide_version,old.source_snapshot_id, + old.source_snapshot_hash,old.setup_run_id,old.setup_generation, + old.canonical_input_hash,old.guide_material_hash,old.pre_catalogue_id, + old.pre_catalogue_version,old.pre_catalogue_schema_version, + old.pre_catalogue_manifest_hash,old.post_catalogue_id,old.post_catalogue_version, + old.post_catalogue_schema_version,old.post_catalogue_manifest_hash, + old.agent_identity,old.agent_version,old.instruction_version, + old.provider_idempotency_key) then raise exception 'compilation attempt identity is immutable'; end if; + if old.status in ('compilation_persisted','compilation_invalid_terminal') then raise exception 'terminal compilation attempt is immutable'; end if; + if new.reserved_at is distinct from old.reserved_at then + raise exception 'compilation reservation timestamp is immutable'; + end if; + if new.provider_uncertain_at is distinct from old.provider_uncertain_at and + not (old.status='compilation_reserved' and new.status='compilation_provider_uncertain') then + raise exception 'provider uncertainty timestamp is immutable'; + end if; + if new.accepted_at is distinct from old.accepted_at and + not (old.status in ('compilation_reserved','compilation_provider_uncertain') and new.status='provider_result_accepted') then + raise exception 'accepted timestamp is immutable'; + end if; + if new.terminal_at is distinct from old.terminal_at and + not (old.status in ('compilation_reserved','compilation_provider_uncertain') and new.status='compilation_invalid_terminal') then + raise exception 'terminal timestamp is immutable'; + end if; + if row(new.persisted_at,new.persisted_compilation_id) is distinct from + row(old.persisted_at,old.persisted_compilation_id) and + not (old.status='provider_result_accepted' and new.status='compilation_persisted') then + raise exception 'persisted custody is immutable'; + end if; + if old.status='provider_result_accepted' and row(new.canonical_result::jsonb,new.result_hash,new.component_hashes::jsonb,new.accepted_at) + is distinct from row(old.canonical_result::jsonb,old.result_hash,old.component_hashes::jsonb,old.accepted_at) then + raise exception 'accepted compilation result is immutable'; + end if; + if not ((old.status='compilation_reserved' and new.status in ('compilation_provider_uncertain','provider_result_accepted','compilation_invalid_terminal')) or + (old.status='compilation_provider_uncertain' and new.status in ('provider_result_accepted','compilation_invalid_terminal')) or + (old.status='provider_result_accepted' and new.status='compilation_persisted')) then + raise exception 'invalid compilation attempt transition'; + end if; + return new; + end $$ + """, + """ + create trigger trg_compilation_attempt_update before update on project_guide_compilation_attempts + for each row execute function guard_project_guide_compilation_attempt_update() + """, + """ + create function reject_project_guide_compilation_mutation() + returns trigger language plpgsql as $$ begin raise exception 'compilation custody is append-only'; end $$ + """, + """ + create trigger trg_compilation_attempt_delete before delete or truncate on project_guide_compilation_attempts + for each statement execute function reject_project_guide_compilation_mutation() + """, + """ + create function guard_project_guide_compilation_insert() + returns trigger language plpgsql as $$ + declare predecessor_generation bigint; + declare source_attempt project_guide_compilation_attempts%rowtype; + begin + select * into source_attempt from project_guide_compilation_attempts + where id=new.attempt_id for update; + if source_attempt.id is null or source_attempt.status <> 'provider_result_accepted' or + row(new.project_id,new.guide_id,new.guide_version,new.source_snapshot_id, + new.source_snapshot_hash,new.setup_run_id,new.setup_generation, + new.canonical_input_hash,new.guide_material_hash, + new.pre_catalogue_manifest_hash,new.post_catalogue_manifest_hash, + new.agent_identity,new.agent_version,new.instruction_version, + new.canonical_result::jsonb,new.result_hash,new.component_hashes::jsonb) + is distinct from + row(source_attempt.project_id,source_attempt.guide_id, + source_attempt.guide_version,source_attempt.source_snapshot_id, + source_attempt.source_snapshot_hash,source_attempt.setup_run_id, + source_attempt.setup_generation,source_attempt.canonical_input_hash, + source_attempt.guide_material_hash,source_attempt.pre_catalogue_manifest_hash, + source_attempt.post_catalogue_manifest_hash,source_attempt.agent_identity, + source_attempt.agent_version,source_attempt.instruction_version, + source_attempt.canonical_result::jsonb,source_attempt.result_hash, + source_attempt.component_hashes::jsonb) then + raise exception 'compilation does not match its accepted attempt'; + end if; + if not exists( + select 1 from audit_events event + join actor_profiles profile on profile.id=new.created_by_actor_profile_id + join actor_identity_links link on link.id=new.created_via_identity_link_id + and link.actor_profile_id=profile.id + where event.id=new.authorization_decision_event_id + and event.event_domain='authority' + and event.event_type='SensitiveAuthorizationAllowed' + and event.denial_code is null + and event.actor_id=new.created_by_actor_profile_id + and event.permission_id='project.guide_compilation.execute' + and event.action_id='project.guide_compilation.execute' + and event.project_id=new.project_id + and event.resource_type='project_guide_compilation_attempt' + and event.resource_id=new.attempt_id::text + and event.after_facts->>'allowed'='true' + and event.after_facts->>'resource_context_digest'= + new.authorization_resource_context_digest + and profile.actor_kind='service' and profile.status='active' + and profile.service_identity='workstream.project.setup' + and link.subject_kind='service' and link.status='active' + and link.issuer='workstream-internal' + and link.subject='workstream.project.setup' + ) then + raise exception 'compilation authorization evidence is invalid'; + end if; + if new.supersedes_compilation_id is null then return new; end if; + select setup_generation into predecessor_generation + from project_guide_compilations + where id=new.supersedes_compilation_id + and project_id=new.project_id and guide_id=new.guide_id; + if predecessor_generation is null or predecessor_generation >= new.setup_generation then + raise exception 'compilation generation must strictly advance'; + end if; + return new; + end $$ + """, + """ + create trigger trg_compilation_insert before insert on project_guide_compilations + for each row execute function guard_project_guide_compilation_insert() + """, + """ + create trigger trg_compilation_mutation before update or delete or truncate on project_guide_compilations + for each statement execute function reject_project_guide_compilation_mutation() + """ + ) + for statement in statements: + op.execute(statement) + + +def downgrade() -> None: + """Remove only the hidden compilation foundation.""" + connection = op.get_bind() + connection.execute(sa.text("lock table audit_events in access exclusive mode")) + retained = connection.execute( + sa.text( + "select exists(select 1 from project_guide_compilation_attempts) or " + "exists(select 1 from project_guide_compilations) or " + "exists(select 1 from audit_events where action_id=:action)" + ), + {"action": _ACTION}, + ).scalar_one() + if retained: + raise RuntimeError("cannot downgrade non-empty guide-compilation custody") + op.execute("drop trigger if exists trg_compilation_mutation on project_guide_compilations") + op.execute("drop trigger if exists trg_compilation_insert on project_guide_compilations") + op.execute("drop trigger if exists trg_compilation_attempt_delete on project_guide_compilation_attempts") + op.execute("drop trigger if exists trg_compilation_attempt_update on project_guide_compilation_attempts") + op.execute("drop function if exists reject_project_guide_compilation_mutation()") + op.execute("drop function if exists guard_project_guide_compilation_insert()") + op.execute("drop function if exists guard_project_guide_compilation_attempt_update()") + op.drop_constraint("fk_compilation_attempt_exact_persisted_compilation", "project_guide_compilation_attempts", type_="foreignkey") + op.drop_table("project_guide_compilations") + op.drop_table("project_guide_compilation_attempts") + _rewrite_action_evidence(add=False) + _rewrite_permission_registry(add=False) + _rewrite_audit_resource(add=False) diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 07e668423..cc21bf571 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -57,6 +57,10 @@ ReviewPolicy, SubmissionArtifactPolicy, ) +from app.modules.projects.guide_compilation.models import ( # noqa: F401 + ProjectGuideCompilation, + ProjectGuideCompilationAttempt, +) from app.modules.reviews.models import ( # noqa: F401 ReviewAdmissionIdempotencyRecord, ReviewLease, diff --git a/backend/app/modules/authorization/api/__init__.py b/backend/app/modules/authorization/api/__init__.py index 46ab4f2e1..a90e5e3f7 100644 --- a/backend/app/modules/authorization/api/__init__.py +++ b/backend/app/modules/authorization/api/__init__.py @@ -10,6 +10,13 @@ ) from .facts import ActorIdentityFacts, ActorKind, JsonScalar, ResourceFacts, ResourceValue from .ports import AuthorizationPort, PreparedAuthorizationPort, PreparedHandleT +from .project_guide_compilation import ( + ProjectGuideCompilationAuthorizationPort, + ProjectGuideCompilationExecutePersistFacts, + ProjectGuideCompilationExecutePreflightFacts, + ProjectGuideCompilationRequestFacts, + project_guide_compilation_execute_resource_digest, +) __all__ = ( "ActionId", @@ -26,6 +33,11 @@ "PreparedAuthorizationInvalid", "PreparedAuthorizationPort", "PreparedHandleT", + "ProjectGuideCompilationAuthorizationPort", + "ProjectGuideCompilationExecutePersistFacts", + "ProjectGuideCompilationExecutePreflightFacts", + "ProjectGuideCompilationRequestFacts", + "project_guide_compilation_execute_resource_digest", "ResourceFacts", "ResourceValue", "action_id", diff --git a/backend/app/modules/authorization/api/project_guide_compilation.py b/backend/app/modules/authorization/api/project_guide_compilation.py new file mode 100644 index 000000000..49e39e284 --- /dev/null +++ b/backend/app/modules/authorization/api/project_guide_compilation.py @@ -0,0 +1,175 @@ +"""Public AUTH contract for hidden unified project-guide compilation.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, fields +import hashlib +import json +import re +from typing import Protocol, TypeVar +from uuid import UUID + +from .facts import ActorIdentityFacts + +_HASH = re.compile(r"sha256:[0-9a-f]{64}\Z") +_TOKEN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,159}\Z") +_UUID_FIELDS = frozenset( + { + "project_id", + "guide_id", + "source_snapshot_id", + "setup_run_id", + "operation_id", + "request_id", + "idempotency_key", + "expected_predecessor_compilation_id", + "attempt_id", + "provider_idempotency_key", + } +) + + +def _validate_common(value: object) -> None: + """Validate bounded scalar values without importing product code.""" + for field in fields(value): + item = getattr(value, field.name) + if field.name == "expected_predecessor_compilation_id" and item is None: + continue + if field.name == "setup_generation": + if type(item) is not int or item <= 0: + raise ValueError("setup generation must be positive") + continue + if field.name in _UUID_FIELDS: + if not isinstance(item, UUID): + raise ValueError(f"{field.name} must be a UUID") + continue + if field.name.endswith(("_hash", "_digest")): + if not isinstance(item, str) or not _HASH.fullmatch(item): + raise ValueError(f"{field.name} must be a canonical SHA-256 digest") + continue + if not isinstance(item, str) or not _TOKEN.fullmatch(item): + raise ValueError(f"{field.name} must be a bounded canonical token") + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ProjectGuideCompilationRequestFacts: + """Exact lineage and configuration facts for one request/recovery.""" + + project_id: UUID + guide_id: UUID + guide_version: str + source_snapshot_id: UUID + source_snapshot_hash: str + canonical_input_hash: str + guide_material_hash: str + setup_run_id: UUID + setup_generation: int + operation_id: UUID + request_id: UUID + idempotency_key: UUID + pre_catalogue_id: str + pre_catalogue_version: str + pre_catalogue_schema_version: str + pre_catalogue_manifest_hash: str + post_catalogue_id: str + post_catalogue_version: str + post_catalogue_schema_version: str + post_catalogue_manifest_hash: str + agent_identity: str + agent_version: str + instruction_version: str + expected_predecessor_compilation_id: UUID | None = None + + def __post_init__(self) -> None: + _validate_common(self) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ProjectGuideCompilationExecutePreflightFacts(ProjectGuideCompilationRequestFacts): + """Exact reserved attempt facts checked before future provider I/O.""" + + attempt_id: UUID + provider_idempotency_key: UUID + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ProjectGuideCompilationExecutePersistFacts( + ProjectGuideCompilationExecutePreflightFacts +): + """Exact accepted-result hashes consumed with immutable persistence.""" + + result_hash: str + sufficiency_component_hash: str + artifact_policy_component_hash: str + requirement_inventory_component_hash: str + pre_submit_policy_component_hash: str + post_submit_policy_component_hash: str + capability_suggestions_component_hash: str + setup_notes_component_hash: str + resource_context_digest: str + + +def project_guide_compilation_execute_resource_digest( + actor: ActorIdentityFacts, + facts: ProjectGuideCompilationExecutePersistFacts, +) -> str: + """Hash the complete public AUTH resource context for final persistence.""" + fact_values = asdict(facts) + fact_values.pop("resource_context_digest") + canonical = json.dumps( + { + "action_id": "project.guide_compilation.execute", + "actor_profile_id": str(actor.actor_profile_id), + "identity_link_id": str(actor.identity_link_id), + "service_identity": actor.service_identity, + "facts": { + key: str(value) if isinstance(value, UUID) else value + for key, value in fact_values.items() + }, + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode() + return "sha256:" + hashlib.sha256(canonical).hexdigest() + + +PreparedHandleT = TypeVar("PreparedHandleT") + + +class ProjectGuideCompilationAuthorizationPort(Protocol[PreparedHandleT]): + """Prepare and consume exact inactive compilation authority.""" + + async def prepare_request( + self, *, actor: ActorIdentityFacts, facts: ProjectGuideCompilationRequestFacts + ) -> PreparedHandleT: ... + + async def consume_request( + self, + *, + handle: PreparedHandleT, + actor: ActorIdentityFacts, + facts: ProjectGuideCompilationRequestFacts, + ) -> UUID: ... + + async def authorize_execute_preflight( + self, + *, + actor: ActorIdentityFacts, + facts: ProjectGuideCompilationExecutePreflightFacts, + ) -> None: ... + + async def prepare_execute_persist( + self, + *, + actor: ActorIdentityFacts, + facts: ProjectGuideCompilationExecutePersistFacts, + ) -> PreparedHandleT: ... + + async def consume_execute_persist( + self, + *, + handle: PreparedHandleT, + actor: ActorIdentityFacts, + facts: ProjectGuideCompilationExecutePersistFacts, + ) -> UUID: ... diff --git a/backend/app/modules/projects/guide_compilation/__init__.py b/backend/app/modules/projects/guide_compilation/__init__.py new file mode 100644 index 000000000..e71d7473b --- /dev/null +++ b/backend/app/modules/projects/guide_compilation/__init__.py @@ -0,0 +1 @@ +"""Hidden unified project-guide compilation foundation.""" diff --git a/backend/app/modules/projects/guide_compilation/authorization.py b/backend/app/modules/projects/guide_compilation/authorization.py new file mode 100644 index 000000000..e801b42e5 --- /dev/null +++ b/backend/app/modules/projects/guide_compilation/authorization.py @@ -0,0 +1,71 @@ +"""Deny-only composition seam for inactive guide compilation authority.""" + +from __future__ import annotations + +from typing import Never + +from app.modules.authorization.api import ( + ActorIdentityFacts, + AuthorizationUnavailable, + ProjectGuideCompilationExecutePersistFacts, + ProjectGuideCompilationExecutePreflightFacts, + ProjectGuideCompilationRequestFacts, +) + + +class DenyProjectGuideCompilationAuthorization: + """Keep every compilation boundary unavailable until AUTH-12I.""" + + @staticmethod + def _deny() -> Never: + """Raise the stable unavailable-authority denial.""" + raise AuthorizationUnavailable("project guide compilation authority is unavailable") + + async def prepare_request( + self, *, actor: ActorIdentityFacts, facts: ProjectGuideCompilationRequestFacts + ) -> Never: + """Deny request preparation before AUTH activates the action.""" + del actor, facts + return self._deny() + + async def consume_request( + self, + *, + handle: object, + actor: ActorIdentityFacts, + facts: ProjectGuideCompilationRequestFacts, + ) -> Never: + """Deny request consumption without inspecting an alleged handle.""" + del handle, actor, facts + return self._deny() + + async def authorize_execute_preflight( + self, + *, + actor: ActorIdentityFacts, + facts: ProjectGuideCompilationExecutePreflightFacts, + ) -> Never: + """Deny fixed-service execution preflight while unavailable.""" + del actor, facts + return self._deny() + + async def prepare_execute_persist( + self, + *, + actor: ActorIdentityFacts, + facts: ProjectGuideCompilationExecutePersistFacts, + ) -> Never: + """Deny accepted-result persistence preparation while unavailable.""" + del actor, facts + return self._deny() + + async def consume_execute_persist( + self, + *, + handle: object, + actor: ActorIdentityFacts, + facts: ProjectGuideCompilationExecutePersistFacts, + ) -> Never: + """Deny persistence consumption without touching product state.""" + del handle, actor, facts + return self._deny() diff --git a/backend/app/modules/projects/guide_compilation/contracts.py b/backend/app/modules/projects/guide_compilation/contracts.py new file mode 100644 index 000000000..c1220aac2 --- /dev/null +++ b/backend/app/modules/projects/guide_compilation/contracts.py @@ -0,0 +1,190 @@ +"""Strict internal values for durable unified guide compilation.""" + +from __future__ import annotations + +from enum import StrEnum +import json +from typing import Any +from uuid import NAMESPACE_URL, UUID, uuid5 + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.core.hashing import canonical_json_hash +from app.interfaces.project_agents import ( + ProjectGuideCompilationContext, + ProjectGuideCompilationResult, + canonical_project_guide_compilation_context_bytes, + validate_project_guide_compilation_result, +) + + +class CompilationAttemptStatus(StrEnum): + """Closed provider-attempt states persisted by the crash fence.""" + + RESERVED = "compilation_reserved" + PROVIDER_UNCERTAIN = "compilation_provider_uncertain" + ACCEPTED = "provider_result_accepted" + INVALID_TERMINAL = "compilation_invalid_terminal" + PERSISTED = "compilation_persisted" + + +class CompilationRecoveryClassification(StrEnum): + """Bounded hidden recovery outcomes safe for operator inspection.""" + + RESERVED = "compilation_reserved" + PROVIDER_UNCERTAIN = "compilation_provider_uncertain" + ACCEPTED_NOT_PERSISTED = "provider_result_accepted_not_persisted" + PERSISTED = "compilation_persisted" + INVALID_TERMINAL = "compilation_invalid_terminal" + + +class CompilationAttemptIdentity(BaseModel): + """Complete immutable identity of one logical provider attempt.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + project_id: UUID + guide_id: UUID + guide_version: str = Field(min_length=1, max_length=50) + source_snapshot_id: UUID + source_snapshot_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + setup_run_id: UUID + setup_generation: int = Field(ge=1) + canonical_input_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + guide_material_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + pre_catalogue_id: str = Field(min_length=1, max_length=160) + pre_catalogue_version: str = Field(min_length=1, max_length=100) + pre_catalogue_schema_version: str = Field(min_length=1, max_length=160) + pre_catalogue_manifest_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + post_catalogue_id: str = Field(min_length=1, max_length=160) + post_catalogue_version: str = Field(min_length=1, max_length=100) + post_catalogue_schema_version: str = Field(min_length=1, max_length=160) + post_catalogue_manifest_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + agent_identity: str = Field(min_length=1, max_length=100) + agent_version: str = Field(min_length=1, max_length=100) + instruction_version: str = Field(min_length=1, max_length=100) + + @classmethod + def from_context( + cls, context: ProjectGuideCompilationContext, *, agent_version: str + ) -> CompilationAttemptIdentity: + """Derive server-owned identity from one strict provider context.""" + material = context.material + return cls( + project_id=UUID(material.project_id), + guide_id=UUID(material.guide_id), + guide_version=material.guide_version, + source_snapshot_id=UUID(material.source_snapshot_id), + source_snapshot_hash=material.source_snapshot_hash, + setup_run_id=context.setup_run_id, + setup_generation=context.setup_generation, + canonical_input_hash=canonical_json_hash( + json.loads(canonical_project_guide_compilation_context_bytes(context)) + ), + guide_material_hash=material.canonical_payload_sha256, + pre_catalogue_id=context.pre_submission_capabilities.catalogue_id, + pre_catalogue_version=context.pre_submission_capabilities.version, + pre_catalogue_schema_version=context.pre_submission_capabilities.schema_version, + pre_catalogue_manifest_hash=context.pre_submission_capabilities.manifest_sha256, + post_catalogue_id=context.post_submission_capabilities.catalogue_id, + post_catalogue_version=context.post_submission_capabilities.source_version, + post_catalogue_schema_version=context.post_submission_capabilities.schema_version, + post_catalogue_manifest_hash=context.post_submission_capabilities.manifest_sha256, + agent_identity=context.agent_identity, + agent_version=agent_version, + instruction_version=context.instruction_version, + ) + + def provider_idempotency_key(self) -> UUID: + """Derive the only provider key permitted for this exact identity.""" + return uuid5( + NAMESPACE_URL, + "workstream.project-guide-compilation-attempt.v1:" + + canonical_json_hash(self.model_dump(mode="json")), + ) + + +class CompilationComponentHashes(BaseModel): + """Named canonical hashes for every independently projected component.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + sufficiency_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + artifact_policy_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + requirement_inventory_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + pre_submit_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + post_submit_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + capability_suggestions_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + setup_notes_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class AcceptedCompilationResult(BaseModel): + """Canonical accepted provider output retained across a crash.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + canonical_result: dict[str, Any] + result_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + component_hashes: CompilationComponentHashes + + @model_validator(mode="after") + def validate_hashes(self) -> AcceptedCompilationResult: + """Reject reconstructed accepted output whose bytes drift.""" + expected = accepted_compilation_result( + ProjectGuideCompilationResult.model_validate(self.canonical_result) + ) + if ( + self.canonical_result != expected.canonical_result + or self.result_hash != expected.result_hash + or self.component_hashes != expected.component_hashes + ): + raise ValueError("accepted compilation result hashes are invalid") + return self + + +def accepted_compilation_result(result: ProjectGuideCompilationResult) -> AcceptedCompilationResult: + """Canonicalize and hash all unified compilation result components.""" + body = result.model_dump(mode="json") + artifact = body["submission_artifact_policy"] + return AcceptedCompilationResult.model_construct( + canonical_result=body, + result_hash=canonical_json_hash(body), + component_hashes=CompilationComponentHashes( + sufficiency_hash=canonical_json_hash( + {"status": body["status"], "findings": body["findings"]} + ), + artifact_policy_hash=canonical_json_hash(artifact), + requirement_inventory_hash=canonical_json_hash( + {"requirements": body["requirements"]} + ), + pre_submit_hash=canonical_json_hash( + {"pre_submit_bindings": body["pre_submit_bindings"]} + ), + post_submit_hash=canonical_json_hash( + {"post_submit_bindings": body["post_submit_bindings"]} + ), + capability_suggestions_hash=canonical_json_hash( + {"capability_suggestions": body["capability_suggestions"]} + ), + setup_notes_hash=canonical_json_hash({"setup_notes": body["setup_notes"]}), + ), + ) + + +def validate_accepted_compilation_result( + *, + identity: CompilationAttemptIdentity, + context: ProjectGuideCompilationContext, + accepted: AcceptedCompilationResult, +) -> ProjectGuideCompilationResult: + """Revalidate stored untrusted output against freshly loaded context.""" + current = CompilationAttemptIdentity.from_context( + context, agent_version=identity.agent_version + ) + if identity != current: + raise ValueError("compilation context no longer matches the attempt") + result = ProjectGuideCompilationResult.model_validate(accepted.canonical_result) + validate_project_guide_compilation_result(context, result) + if accepted != accepted_compilation_result(result): + raise ValueError("accepted compilation result no longer matches its hashes") + return result diff --git a/backend/app/modules/projects/guide_compilation/models.py b/backend/app/modules/projects/guide_compilation/models.py new file mode 100644 index 000000000..44588a97a --- /dev/null +++ b/backend/app/modules/projects/guide_compilation/models.py @@ -0,0 +1,281 @@ +"""Isolated SQLAlchemy models for hidden unified guide compilation.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + DateTime, + ForeignKey, + ForeignKeyConstraint, + Index, + JSON, + String, + UniqueConstraint, + Uuid, + text, +) +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.base import Base + +_HASH_CHECK = "~ '^sha256:[0-9a-f]{64}$'" +_COMPONENT_NAMES = ( + "sufficiency_hash", + "artifact_policy_hash", + "requirement_inventory_hash", + "pre_submit_hash", + "post_submit_hash", + "capability_suggestions_hash", + "setup_notes_hash", +) + + +def _component_hashes_check(column: str) -> str: + """Return the exact seven-key JSON shape and digest checks.""" + pairs = ",".join(f"'{name}',{column}->>'{name}'" for name in _COMPONENT_NAMES) + hashes = " and ".join( + f"coalesce(({column}->>'{name}') {_HASH_CHECK},false)" + for name in _COMPONENT_NAMES + ) + return ( + f"json_typeof({column})='object' and " + f"{column}::jsonb=jsonb_build_object({pairs}) and {hashes}" + ) + + +class ProjectGuideCompilationAttempt(Base): + """Crash fence for one exact setup-generation provider attempt.""" + + __tablename__ = "project_guide_compilation_attempts" + __table_args__ = ( + ForeignKeyConstraint( + ["source_snapshot_id", "source_snapshot_hash"], + ["guide_source_snapshots.id", "guide_source_snapshots.bundle_hash"], + name="fk_compilation_attempt_snapshot_hash", + ), + ForeignKeyConstraint( + [ + "setup_run_id", + "project_id", + "guide_id", + "source_snapshot_id", + "setup_generation", + ], + [ + "project_setup_runs.id", + "project_setup_runs.project_id", + "project_setup_runs.guide_id", + "project_setup_runs.source_snapshot_id", + "project_setup_runs.setup_generation", + ], + name="fk_compilation_attempt_exact_setup", + ), + ForeignKeyConstraint( + ["persisted_compilation_id", "id"], + ["project_guide_compilations.id", "project_guide_compilations.attempt_id"], + name="fk_compilation_attempt_exact_persisted_compilation", + use_alter=True, + ), + UniqueConstraint( + "setup_run_id", + "setup_generation", + name="uq_compilation_attempt_setup_generation", + ), + UniqueConstraint( + "provider_idempotency_key", name="uq_compilation_attempt_provider_key" + ), + CheckConstraint( + "status in ('compilation_reserved','compilation_provider_uncertain'," + "'provider_result_accepted','compilation_invalid_terminal'," + "'compilation_persisted')", + name="ck_compilation_attempt_status", + ), + CheckConstraint( + "setup_generation > 0", name="ck_compilation_attempt_generation" + ), + CheckConstraint( + "source_snapshot_hash " + _HASH_CHECK + " and canonical_input_hash " + + _HASH_CHECK + " and guide_material_hash " + + _HASH_CHECK + + " and pre_catalogue_manifest_hash " + + _HASH_CHECK + + " and post_catalogue_manifest_hash " + + _HASH_CHECK, + name="ck_compilation_attempt_identity_hashes", + ), + CheckConstraint( + "result_hash is null or result_hash " + _HASH_CHECK, + name="ck_compilation_attempt_result_hash", + ), + CheckConstraint( + "canonical_result is null or octet_length(canonical_result::text) <= 4194304", + name="ck_compilation_attempt_result_size", + ), + CheckConstraint( + "component_hashes is null or (" + + _component_hashes_check("component_hashes") + + ")", + name="ck_compilation_attempt_component_hashes", + ), + CheckConstraint( + "(status='compilation_reserved' and provider_uncertain_at is null and accepted_at is null " + "and terminal_at is null and persisted_at is null and canonical_result is null " + "and result_hash is null and component_hashes is null and failure_code is null " + "and persisted_compilation_id is null) or " + "(status='compilation_provider_uncertain' and provider_uncertain_at is not null " + "and accepted_at is null and terminal_at is null and persisted_at is null " + "and canonical_result is null and result_hash is null and component_hashes is null " + "and failure_code is null and persisted_compilation_id is null) or " + "(status='provider_result_accepted' and accepted_at is not null and terminal_at is null " + "and persisted_at is null and canonical_result is not null and result_hash is not null " + "and component_hashes is not null and failure_code is null " + "and persisted_compilation_id is null) or " + "(status='compilation_persisted' and accepted_at is not null and persisted_at is not null " + "and terminal_at is null and canonical_result is not null and result_hash is not null " + "and component_hashes is not null and failure_code is null " + "and persisted_compilation_id is not null) or " + "(status='compilation_invalid_terminal' and terminal_at is not null and accepted_at is null and persisted_at is null " + "and canonical_result is null and result_hash is null and component_hashes is null " + "and persisted_compilation_id is null and " + "failure_code in ('schema_invalid','unsafe_text','hash_mismatch','context_mismatch'))", + name="ck_compilation_attempt_state_shape", + ), + ) + + id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) + project_id: Mapped[str] = mapped_column(ForeignKey("projects.id"), index=True) + guide_id: Mapped[str] = mapped_column(ForeignKey("project_guides.id"), index=True) + guide_version: Mapped[str] = mapped_column(String(50)) + source_snapshot_id: Mapped[str] = mapped_column(String(36), index=True) + source_snapshot_hash: Mapped[str] = mapped_column(String(71)) + setup_run_id: Mapped[str] = mapped_column(String(36), index=True) + setup_generation: Mapped[int] = mapped_column(BigInteger) + canonical_input_hash: Mapped[str] = mapped_column(String(71)) + guide_material_hash: Mapped[str] = mapped_column(String(71)) + pre_catalogue_id: Mapped[str] = mapped_column(String(160)) + pre_catalogue_version: Mapped[str] = mapped_column(String(100)) + pre_catalogue_schema_version: Mapped[str] = mapped_column(String(160)) + pre_catalogue_manifest_hash: Mapped[str] = mapped_column(String(71)) + post_catalogue_id: Mapped[str] = mapped_column(String(160)) + post_catalogue_version: Mapped[str] = mapped_column(String(100)) + post_catalogue_schema_version: Mapped[str] = mapped_column(String(160)) + post_catalogue_manifest_hash: Mapped[str] = mapped_column(String(71)) + agent_identity: Mapped[str] = mapped_column(String(100)) + agent_version: Mapped[str] = mapped_column(String(100)) + instruction_version: Mapped[str] = mapped_column(String(100)) + provider_idempotency_key: Mapped[UUID] = mapped_column(Uuid()) + status: Mapped[str] = mapped_column(String(32)) + canonical_result: Mapped[dict | None] = mapped_column(JSON) + result_hash: Mapped[str | None] = mapped_column(String(71)) + component_hashes: Mapped[dict | None] = mapped_column(JSON) + failure_code: Mapped[str | None] = mapped_column(String(100)) + persisted_compilation_id: Mapped[UUID | None] = mapped_column(Uuid()) + reserved_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + provider_uncertain_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + terminal_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + persisted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class ProjectGuideCompilation(Base): + """Immutable accepted compilation in one append-only guide lineage.""" + + __tablename__ = "project_guide_compilations" + __table_args__ = ( + UniqueConstraint("attempt_id", name="uq_project_guide_compilation_attempt"), + UniqueConstraint( + "id", "attempt_id", name="uq_project_guide_compilation_id_attempt" + ), + UniqueConstraint( + "supersedes_compilation_id", name="uq_project_guide_compilation_predecessor" + ), + UniqueConstraint( + "id", "project_id", "guide_id", name="uq_project_guide_compilation_scope" + ), + ForeignKeyConstraint( + ["supersedes_compilation_id", "project_id", "guide_id"], + [ + "project_guide_compilations.id", + "project_guide_compilations.project_id", + "project_guide_compilations.guide_id", + ], + name="fk_project_guide_compilation_predecessor", + ), + Index( + "uq_project_guide_compilation_root", + "project_id", + "guide_id", + unique=True, + postgresql_where=text("supersedes_compilation_id is null"), + ), + CheckConstraint( + "setup_generation > 0 and created_by_service_identity = " + "'workstream.project.setup' and creation_action_id = " + "'project.guide_compilation.execute'", + name="ck_project_guide_compilation_custody", + ), + CheckConstraint( + "authorization_resource_context_digest " + _HASH_CHECK, + name="ck_project_guide_compilation_authorization_digest", + ), + CheckConstraint( + "source_snapshot_hash " + _HASH_CHECK + " and canonical_input_hash " + + _HASH_CHECK + " and guide_material_hash " + + _HASH_CHECK + + " and pre_catalogue_manifest_hash " + + _HASH_CHECK + + " and post_catalogue_manifest_hash " + + _HASH_CHECK + + " and result_hash " + + _HASH_CHECK, + name="ck_project_guide_compilation_hashes", + ), + CheckConstraint( + "octet_length(canonical_result::text) <= 4194304 and " + + _component_hashes_check("component_hashes"), + name="ck_project_guide_compilation_result_shape", + ), + ) + + id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) + attempt_id: Mapped[UUID] = mapped_column( + Uuid(), ForeignKey("project_guide_compilation_attempts.id"), nullable=False + ) + project_id: Mapped[str] = mapped_column(ForeignKey("projects.id"), index=True) + guide_id: Mapped[str] = mapped_column(ForeignKey("project_guides.id"), index=True) + guide_version: Mapped[str] = mapped_column(String(50)) + source_snapshot_id: Mapped[str] = mapped_column( + ForeignKey("guide_source_snapshots.id"), index=True + ) + source_snapshot_hash: Mapped[str] = mapped_column(String(71)) + setup_run_id: Mapped[str] = mapped_column(ForeignKey("project_setup_runs.id"), index=True) + setup_generation: Mapped[int] = mapped_column(BigInteger) + canonical_input_hash: Mapped[str] = mapped_column(String(71)) + guide_material_hash: Mapped[str] = mapped_column(String(71)) + pre_catalogue_manifest_hash: Mapped[str] = mapped_column(String(71)) + post_catalogue_manifest_hash: Mapped[str] = mapped_column(String(71)) + agent_identity: Mapped[str] = mapped_column(String(100)) + agent_version: Mapped[str] = mapped_column(String(100)) + instruction_version: Mapped[str] = mapped_column(String(100)) + canonical_result: Mapped[dict] = mapped_column(JSON) + result_hash: Mapped[str] = mapped_column(String(71)) + component_hashes: Mapped[dict] = mapped_column(JSON) + supersedes_compilation_id: Mapped[UUID | None] = mapped_column(Uuid()) + created_by_actor_profile_id: Mapped[str] = mapped_column(ForeignKey("actor_profiles.id")) + created_via_identity_link_id: Mapped[str] = mapped_column( + ForeignKey("actor_identity_links.id") + ) + created_by_service_identity: Mapped[str] = mapped_column(String(160)) + creation_action_id: Mapped[str] = mapped_column(String(160)) + authorization_decision_event_id: Mapped[str] = mapped_column(ForeignKey("audit_events.id")) + authorization_resource_context_digest: Mapped[str] = mapped_column(String(71)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) diff --git a/backend/app/modules/projects/guide_compilation/repository.py b/backend/app/modules/projects/guide_compilation/repository.py new file mode 100644 index 000000000..dc49309c7 --- /dev/null +++ b/backend/app/modules/projects/guide_compilation/repository.py @@ -0,0 +1,384 @@ +"""Short-transaction repository for hidden guide compilation custody.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Literal +from uuid import UUID, uuid4 + +from sqlalchemy import exists, select, update +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.errors import integrity_constraint_name +from app.interfaces.project_agents import ( + ProjectGuideCompilationContext, + ProjectGuideCompilationResult, +) +from app.modules.authorization.api import ( + ActorIdentityFacts, + ProjectGuideCompilationExecutePersistFacts, +) + +from .contracts import ( + CompilationAttemptIdentity, + CompilationRecoveryClassification, + accepted_compilation_result, + validate_accepted_compilation_result, +) +from .models import ProjectGuideCompilation, ProjectGuideCompilationAttempt +from .validation import ( + accepted_from_attempt, + identity_from_attempt, + validate_persistence_authority, + validate_terminal_failure_code, +) + + +class GuideCompilationIntegrityError(RuntimeError): + """A durable compilation invariant was absent, stale, or mismatched.""" + + +class GuideCompilationConcurrencyError(GuideCompilationIntegrityError): + """A concurrent lineage append won; callers must reload the tip and retry.""" + + +class GuideCompilationStorageError(GuideCompilationIntegrityError): + """An unexpected database failure prevented a custody write.""" + + +_LINEAGE_CONSTRAINTS = frozenset( + { + "uq_project_guide_compilation_predecessor", + "uq_project_guide_compilation_root", + } +) + + +def _persistence_error(exc: DBAPIError) -> GuideCompilationIntegrityError: + """Classify a database write failure without leaking driver exceptions.""" + if integrity_constraint_name(exc) in _LINEAGE_CONSTRAINTS: # type: ignore[arg-type] + return GuideCompilationConcurrencyError( + "concurrent compilation append won; reload the lineage tip and retry" + ) + return GuideCompilationStorageError( + "compilation persistence failed before durable custody" + ) + + +class GuideCompilationRepository: + """Persist one hidden attempt and append-only compilation graph.""" + + def __init__(self, session: AsyncSession) -> None: + """Bind repository operations to the caller-owned transaction.""" + self._session = session + + async def reserve_attempt( + self, identity: CompilationAttemptIdentity + ) -> tuple[Literal["claimed", "existing", "mismatch"], ProjectGuideCompilationAttempt]: + """Claim or classify the sole attempt for one setup generation.""" + values = _identity_values(identity) + values.update( + id=uuid4(), + provider_idempotency_key=identity.provider_idempotency_key(), + status="compilation_reserved", + ) + claimed = await self._session.scalar( + insert(ProjectGuideCompilationAttempt) + .values(**values) + .on_conflict_do_nothing() + .returning(ProjectGuideCompilationAttempt.id) + ) + if claimed is not None: + return "claimed", await self._required_attempt(claimed) + attempt = await self._session.scalar( + select(ProjectGuideCompilationAttempt).where( + ProjectGuideCompilationAttempt.setup_run_id == str(identity.setup_run_id), + ProjectGuideCompilationAttempt.setup_generation == identity.setup_generation, + ) + ) + if attempt is None: + raise GuideCompilationIntegrityError("compilation reservation disappeared") + outcome = "existing" if _matches(attempt, identity) else "mismatch" + return outcome, attempt + + async def mark_provider_uncertain(self, attempt_id: UUID) -> ProjectGuideCompilationAttempt: + """Fence an unknown provider result under the original key.""" + attempt = await self._lock_attempt(attempt_id) + if attempt.status == "compilation_provider_uncertain": + return attempt + if attempt.status != "compilation_reserved": + raise GuideCompilationIntegrityError("invalid provider-uncertain transition") + await self._transition( + attempt_id, + expected=("compilation_reserved",), + status="compilation_provider_uncertain", + provider_uncertain_at=datetime.now(UTC), + ) + return await self._required_attempt(attempt_id) + + async def accept_result( + self, + *, + attempt_id: UUID, + context: ProjectGuideCompilationContext, + result: ProjectGuideCompilationResult, + ) -> ProjectGuideCompilationAttempt: + """Store one revalidated canonical result before compilation insertion.""" + attempt = await self._lock_attempt(attempt_id) + try: + identity = identity_from_attempt(attempt) + accepted = accepted_compilation_result(result) + validate_accepted_compilation_result( + identity=identity, context=context, accepted=accepted + ) + except ValueError as exc: + raise GuideCompilationIntegrityError( + "accepted compilation result is invalid" + ) from exc + if attempt.status in {"provider_result_accepted", "compilation_persisted"}: + if accepted_from_attempt(attempt) != accepted: + raise GuideCompilationIntegrityError("accepted result mismatch") + return attempt + if attempt.status not in { + "compilation_reserved", + "compilation_provider_uncertain", + }: + raise GuideCompilationIntegrityError("invalid accepted transition") + await self._transition( + attempt_id, + expected=("compilation_reserved", "compilation_provider_uncertain"), + status="provider_result_accepted", + canonical_result=accepted.canonical_result, + result_hash=accepted.result_hash, + component_hashes=accepted.component_hashes.model_dump(mode="json"), + accepted_at=datetime.now(UTC), + ) + return await self._required_attempt(attempt_id) + + async def mark_invalid_terminal( + self, *, attempt_id: UUID, failure_code: str + ) -> ProjectGuideCompilationAttempt: + """Terminally consume a generation after invalid or unsafe output.""" + try: + failure_code = validate_terminal_failure_code(failure_code) + except ValueError as exc: + raise GuideCompilationIntegrityError( + "terminal compilation failure code is invalid" + ) from exc + attempt = await self._lock_attempt(attempt_id) + if ( + attempt.status == "compilation_invalid_terminal" + and attempt.failure_code == failure_code + ): + return attempt + if attempt.status not in { + "compilation_reserved", + "compilation_provider_uncertain", + }: + raise GuideCompilationIntegrityError("invalid terminal transition") + await self._transition( + attempt_id, + expected=("compilation_reserved", "compilation_provider_uncertain"), + status="compilation_invalid_terminal", + failure_code=failure_code, + terminal_at=datetime.now(UTC), + ) + return await self._required_attempt(attempt_id) + + async def recovery_classification( + self, attempt_id: UUID + ) -> CompilationRecoveryClassification: + """Return one bounded hidden recovery classification.""" + attempt = await self._required_attempt(attempt_id) + if attempt.status == "provider_result_accepted": + return CompilationRecoveryClassification.ACCEPTED_NOT_PERSISTED + return CompilationRecoveryClassification(attempt.status) + + async def persist_accepted( + self, + *, + attempt_id: UUID, + context: ProjectGuideCompilationContext, + expected_predecessor_id: UUID | None, + actor: ActorIdentityFacts, + facts: ProjectGuideCompilationExecutePersistFacts, + authorization_decision_event_id: UUID, + ) -> ProjectGuideCompilation: + """CAS-insert one immutable compilation and finish its attempt.""" + attempt = await self._lock_attempt(attempt_id) + existing = await self._compilation_for_attempt(attempt_id) + if attempt.status not in {"provider_result_accepted", "compilation_persisted"}: + raise GuideCompilationIntegrityError("attempt is not ready for persistence") + try: + accepted = accepted_from_attempt(attempt) + identity = identity_from_attempt(attempt) + validate_accepted_compilation_result( + identity=identity, context=context, accepted=accepted + ) + validate_persistence_authority( + attempt=attempt, + accepted=accepted, + actor=actor, + facts=facts, + expected_predecessor_id=expected_predecessor_id, + ) + assert actor.service_identity is not None + except ValueError as exc: + raise GuideCompilationIntegrityError( + "accepted compilation custody is invalid" + ) from exc + if attempt.status == "compilation_persisted": + if existing is None or attempt.persisted_compilation_id != existing.id: + raise GuideCompilationIntegrityError("persisted compilation is missing") + return existing + if existing is not None: + raise GuideCompilationIntegrityError("attempt is not ready for persistence") + current = await self._current(identity.project_id, identity.guide_id, lock=True) + if (current.id if current else None) != expected_predecessor_id: + raise GuideCompilationConcurrencyError( + "compilation predecessor is stale; reload the lineage tip and retry" + ) + if current and current.setup_generation >= identity.setup_generation: + raise GuideCompilationIntegrityError("compilation generation did not advance") + compilation_id = uuid4() + compilation = ProjectGuideCompilation( + id=compilation_id, + attempt_id=attempt.id, + **_compilation_identity_values(identity), + canonical_result=accepted.canonical_result, + result_hash=accepted.result_hash, + component_hashes=accepted.component_hashes.model_dump(mode="json"), + supersedes_compilation_id=expected_predecessor_id, + created_by_actor_profile_id=str(actor.actor_profile_id), + created_via_identity_link_id=str(actor.identity_link_id), + created_by_service_identity=actor.service_identity, + creation_action_id="project.guide_compilation.execute", + authorization_decision_event_id=str(authorization_decision_event_id), + authorization_resource_context_digest=facts.resource_context_digest, + ) + self._session.add(compilation) + try: + await self._session.flush() + except DBAPIError as exc: + raise _persistence_error(exc) from exc + await self._transition( + attempt_id, + expected=("provider_result_accepted",), + status="compilation_persisted", + persisted_compilation_id=compilation_id, + persisted_at=datetime.now(UTC), + ) + await self._session.refresh(compilation) + return compilation + + async def _transition( + self, attempt_id: UUID, *, expected: tuple[str, ...], status: str, **values: object + ) -> None: + """Apply one compare-and-set attempt transition or fail closed.""" + changed = await self._session.scalar( + update(ProjectGuideCompilationAttempt) + .where( + ProjectGuideCompilationAttempt.id == attempt_id, + ProjectGuideCompilationAttempt.status.in_(expected), + ) + .values(status=status, **values) + .returning(ProjectGuideCompilationAttempt.id) + ) + if changed is None: + raise GuideCompilationIntegrityError("compilation transition lost") + + async def _lock_attempt(self, attempt_id: UUID) -> ProjectGuideCompilationAttempt: + """Lock and return the exact durable attempt.""" + attempt = await self._session.scalar( + select(ProjectGuideCompilationAttempt) + .where(ProjectGuideCompilationAttempt.id == attempt_id) + .with_for_update() + ) + if attempt is None: + raise GuideCompilationIntegrityError("compilation attempt was not found") + return attempt + + async def _required_attempt(self, attempt_id: UUID) -> ProjectGuideCompilationAttempt: + """Load one required attempt and refresh its database-owned state.""" + attempt = await self._session.get( + ProjectGuideCompilationAttempt, + attempt_id, + populate_existing=True, + ) + if attempt is None: + raise GuideCompilationIntegrityError("compilation attempt disappeared") + return attempt + + async def _compilation_for_attempt( + self, attempt_id: UUID + ) -> ProjectGuideCompilation | None: + """Return the immutable compilation already owned by an attempt.""" + return await self._session.scalar( + select(ProjectGuideCompilation).where( + ProjectGuideCompilation.attempt_id == attempt_id + ) + ) + + async def _current( + self, project_id: UUID, guide_id: UUID, *, lock: bool + ) -> ProjectGuideCompilation | None: + """Return the sole unsuperseded compilation, optionally locked.""" + child = ProjectGuideCompilation.__table__.alias("compilation_child") + statement = select(ProjectGuideCompilation).where( + ProjectGuideCompilation.project_id == str(project_id), + ProjectGuideCompilation.guide_id == str(guide_id), + ~exists( + select(1).where( + child.c.supersedes_compilation_id == ProjectGuideCompilation.id + ) + ), + ) + if lock: + statement = statement.with_for_update() + rows = list((await self._session.scalars(statement)).all()) + if len(rows) > 1: + raise GuideCompilationIntegrityError("multiple current compilations") + return rows[0] if rows else None + + +def _identity_values(identity: CompilationAttemptIdentity) -> dict[str, object]: + """Map validated attempt identity into explicit database values.""" + values = identity.model_dump(mode="json") + values.update( + project_id=str(identity.project_id), + guide_id=str(identity.guide_id), + source_snapshot_id=str(identity.source_snapshot_id), + setup_run_id=str(identity.setup_run_id), + ) + return values + + +def _matches( + attempt: ProjectGuideCompilationAttempt, identity: CompilationAttemptIdentity +) -> bool: + """Return whether a row retains the exact identity and provider key.""" + return identity_from_attempt(attempt) == identity and ( + attempt.provider_idempotency_key == identity.provider_idempotency_key() + ) + + +def _compilation_identity_values(identity: CompilationAttemptIdentity) -> dict[str, object]: + """Map immutable compilation lineage fields from the attempt identity.""" + return { + "project_id": str(identity.project_id), + "guide_id": str(identity.guide_id), + "guide_version": identity.guide_version, + "source_snapshot_id": str(identity.source_snapshot_id), + "source_snapshot_hash": identity.source_snapshot_hash, + "setup_run_id": str(identity.setup_run_id), + "setup_generation": identity.setup_generation, + "canonical_input_hash": identity.canonical_input_hash, + "guide_material_hash": identity.guide_material_hash, + "pre_catalogue_manifest_hash": identity.pre_catalogue_manifest_hash, + "post_catalogue_manifest_hash": identity.post_catalogue_manifest_hash, + "agent_identity": identity.agent_identity, + "agent_version": identity.agent_version, + "instruction_version": identity.instruction_version, + } diff --git a/backend/app/modules/projects/guide_compilation/validation.py b/backend/app/modules/projects/guide_compilation/validation.py new file mode 100644 index 000000000..5f081eabc --- /dev/null +++ b/backend/app/modules/projects/guide_compilation/validation.py @@ -0,0 +1,124 @@ +"""Revalidation helpers for untrusted durable compilation values.""" + +from __future__ import annotations + +from uuid import UUID + +from app.modules.authorization.api import ( + ActorIdentityFacts, + ActorKind, + ProjectGuideCompilationExecutePersistFacts, + project_guide_compilation_execute_resource_digest, +) + +from .contracts import ( + AcceptedCompilationResult, + CompilationAttemptIdentity, + CompilationComponentHashes, +) +from .models import ProjectGuideCompilationAttempt + +TERMINAL_FAILURE_CODES = frozenset( + {"context_mismatch", "hash_mismatch", "schema_invalid", "unsafe_text"} +) + + +def identity_from_attempt(attempt: ProjectGuideCompilationAttempt) -> CompilationAttemptIdentity: + """Reconstruct the strict identity held by a locked attempt.""" + return CompilationAttemptIdentity( + project_id=UUID(attempt.project_id), + guide_id=UUID(attempt.guide_id), + guide_version=attempt.guide_version, + source_snapshot_id=UUID(attempt.source_snapshot_id), + source_snapshot_hash=attempt.source_snapshot_hash, + setup_run_id=UUID(attempt.setup_run_id), + setup_generation=attempt.setup_generation, + canonical_input_hash=attempt.canonical_input_hash, + guide_material_hash=attempt.guide_material_hash, + pre_catalogue_id=attempt.pre_catalogue_id, + pre_catalogue_version=attempt.pre_catalogue_version, + pre_catalogue_schema_version=attempt.pre_catalogue_schema_version, + pre_catalogue_manifest_hash=attempt.pre_catalogue_manifest_hash, + post_catalogue_id=attempt.post_catalogue_id, + post_catalogue_version=attempt.post_catalogue_version, + post_catalogue_schema_version=attempt.post_catalogue_schema_version, + post_catalogue_manifest_hash=attempt.post_catalogue_manifest_hash, + agent_identity=attempt.agent_identity, + agent_version=attempt.agent_version, + instruction_version=attempt.instruction_version, + ) + + +def accepted_from_attempt(attempt: ProjectGuideCompilationAttempt) -> AcceptedCompilationResult: + """Parse complete accepted custody or fail closed.""" + if ( + attempt.canonical_result is None + or attempt.result_hash is None + or attempt.component_hashes is None + ): + raise ValueError("accepted compilation result is incomplete") + return AcceptedCompilationResult( + canonical_result=attempt.canonical_result, + result_hash=attempt.result_hash, + component_hashes=attempt.component_hashes, + ) + + +def validate_persistence_authority( + *, + attempt: ProjectGuideCompilationAttempt, + accepted: AcceptedCompilationResult, + actor: ActorIdentityFacts, + facts: ProjectGuideCompilationExecutePersistFacts, + expected_predecessor_id: UUID | None, +) -> None: + """Bind fixed-service custody to exact attempt and accepted hashes.""" + identity = identity_from_attempt(attempt) + expected_hashes = CompilationComponentHashes( + sufficiency_hash=facts.sufficiency_component_hash, + artifact_policy_hash=facts.artifact_policy_component_hash, + requirement_inventory_hash=facts.requirement_inventory_component_hash, + pre_submit_hash=facts.pre_submit_policy_component_hash, + post_submit_hash=facts.post_submit_policy_component_hash, + capability_suggestions_hash=facts.capability_suggestions_component_hash, + setup_notes_hash=facts.setup_notes_component_hash, + ) + if ( + actor.actor_kind is not ActorKind.SERVICE + or actor.service_identity != "workstream.project.setup" + or facts.attempt_id != attempt.id + or facts.provider_idempotency_key != attempt.provider_idempotency_key + or facts.project_id != identity.project_id + or facts.guide_id != identity.guide_id + or facts.guide_version != identity.guide_version + or facts.source_snapshot_id != identity.source_snapshot_id + or facts.source_snapshot_hash != identity.source_snapshot_hash + or facts.canonical_input_hash != identity.canonical_input_hash + or facts.guide_material_hash != identity.guide_material_hash + or facts.setup_run_id != identity.setup_run_id + or facts.setup_generation != identity.setup_generation + or facts.pre_catalogue_id != identity.pre_catalogue_id + or facts.pre_catalogue_version != identity.pre_catalogue_version + or facts.pre_catalogue_schema_version != identity.pre_catalogue_schema_version + or facts.pre_catalogue_manifest_hash != identity.pre_catalogue_manifest_hash + or facts.post_catalogue_id != identity.post_catalogue_id + or facts.post_catalogue_version != identity.post_catalogue_version + or facts.post_catalogue_schema_version != identity.post_catalogue_schema_version + or facts.post_catalogue_manifest_hash != identity.post_catalogue_manifest_hash + or facts.agent_identity != identity.agent_identity + or facts.agent_version != identity.agent_version + or facts.instruction_version != identity.instruction_version + or facts.expected_predecessor_compilation_id != expected_predecessor_id + or facts.result_hash != accepted.result_hash + or expected_hashes != accepted.component_hashes + or facts.resource_context_digest + != project_guide_compilation_execute_resource_digest(actor, facts) + ): + raise ValueError("compilation persistence authority mismatch") + + +def validate_terminal_failure_code(value: str) -> str: + """Return one bounded allowlisted terminal reason.""" + if value not in TERMINAL_FAILURE_CODES: + raise ValueError("compilation failure code is invalid") + return value diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index b8f15a53d..a7df0f5c7 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -71,6 +71,18 @@ "backend/scripts/test_structure_boundary.py", } ) +POL_03A_CALLABLE_TARGETS = frozenset( + { + "backend/app/modules/authorization/api/project_guide_compilation.py", + "backend/app/modules/projects/guide_compilation/authorization.py", + "backend/app/modules/projects/guide_compilation/contracts.py", + "backend/app/modules/projects/guide_compilation/repository.py", + "backend/app/modules/projects/guide_compilation/validation.py", + } +) +POL_03A_DECLARATIVE_MODEL_TARGET = ( + "backend/app/modules/projects/guide_compilation/models.py" +) class BehaviorOwnershipError(RuntimeError): @@ -211,7 +223,10 @@ def _validate_additive_partition_transition( ): raise BehaviorOwnershipError("untrusted_partition_change") additions = set(current_by_target) - set(trusted_targets) - expected_additions = AUTH_BOUNDARY_FOUNDATION_TARGETS - set(trusted_targets) + approved_additions = AUTH_BOUNDARY_FOUNDATION_TARGETS | POL_03A_CALLABLE_TARGETS + expected_additions = (approved_additions & additions) - set(trusted_targets) + if POL_03A_DECLARATIVE_MODEL_TARGET in additions: + expected_additions = expected_additions | {POL_03A_DECLARATIVE_MODEL_TARGET} if additions != expected_additions: raise BehaviorOwnershipError("untrusted_partition_change") if any( @@ -497,7 +512,7 @@ def validate_catalogue( covered = {item["target"] for item in records} expected = {target for target, assigned in partition.items() if group in (None, assigned)} unresolved = expected - covered - if unresolved.intersection(AUTH_BOUNDARY_FOUNDATION_TARGETS): + if unresolved.intersection(AUTH_BOUNDARY_FOUNDATION_TARGETS | POL_03A_CALLABLE_TARGETS): raise BehaviorOwnershipError("unresolved_auth_boundary_foundation") return { "schema": CATALOGUE_SCHEMA, diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py index bb61d48c6..88eaf3292 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -158,6 +158,12 @@ class TestLane: TestLane( "project_lifecycle", ( + "tests/projects/guide_compilation/test_contracts.py", + "tests/projects/guide_compilation/test_database_guards.py", + "tests/projects/guide_compilation/test_migration_contract.py", + "tests/projects/guide_compilation/test_public_authorization.py", + "tests/projects/guide_compilation/test_repository_attempts.py", + "tests/projects/guide_compilation/test_repository_persistence.py", "tests/test_projects.py", ), ), diff --git a/backend/scripts/test_structure_boundary.py b/backend/scripts/test_structure_boundary.py index 942cc72de..c0e574a94 100644 --- a/backend/scripts/test_structure_boundary.py +++ b/backend/scripts/test_structure_boundary.py @@ -22,6 +22,8 @@ SCHEMA = "workstream.auth-test-structure-debt.v1" MAP_SCHEMA = "workstream.auth-assertion-map.v1" PRODUCTION_ROOT = "backend/app/modules/authorization" +POL_03A_PRODUCTION_ROOT = "backend/app/modules/projects/guide_compilation" +POL_03A_TEST_ROOT = "backend/tests/projects/guide_compilation" RECOVERY_PATHS = ( "backend/scripts/authorization_boundary.py", "backend/scripts/test_structure_boundary.py", @@ -126,7 +128,7 @@ def _imports_authorization(tree: ast.Module) -> bool: def scoped_test_paths(root: Path) -> list[Path]: - """Return exact AUTH-related tests plus recovery architecture tests.""" + """Return AUTH recovery tests and every focused POL-03A test.""" tests_root = root / "backend" / "tests" result: set[Path] = set() for path in tests_root.rglob("*.py"): @@ -140,6 +142,9 @@ def scoped_test_paths(root: Path) -> list[Path]: path = root / value if value.startswith("backend/tests/") and path.is_file(): result.add(path) + pol03a_tests = root / POL_03A_TEST_ROOT + if pol03a_tests.is_dir(): + result.update(pol03a_tests.rglob("*.py")) return sorted(result) @@ -232,6 +237,7 @@ def _debt_for_path(path: Path, root: Path, *, test_file: bool) -> list[DebtItem] def observed_debt(root: Path) -> list[DebtItem]: """Inventory all current hard-limit violations in the AUTH recovery scope.""" production = sorted((root / PRODUCTION_ROOT).rglob("*.py")) + production.extend(sorted((root / POL_03A_PRODUCTION_ROOT).rglob("*.py"))) recovery_scripts = [ root / value for value in RECOVERY_PATHS diff --git a/backend/tests/architecture/test_test_structure_boundary.py b/backend/tests/architecture/test_test_structure_boundary.py index 6ccdb4dfe..b0d313194 100644 --- a/backend/tests/architecture/test_test_structure_boundary.py +++ b/backend/tests/architecture/test_test_structure_boundary.py @@ -31,6 +31,29 @@ def test_repository_structural_debt_equals_the_frozen_ledger() -> None: structure.validate(ROOT, POLICY, LEDGER) +def test_pol03a_production_package_is_inside_zero_growth_scope() -> None: + """The modular compilation package cannot escape production size enforcement.""" + scoped = ROOT / structure.POL_03A_PRODUCTION_ROOT + assert scoped == ROOT / "backend/app/modules/projects/guide_compilation" + assert scoped.is_dir() + + +def test_every_pol03a_test_is_inside_zero_growth_scope() -> None: + """No focused compilation test can escape size or weakening detection.""" + expected = set((ROOT / structure.POL_03A_TEST_ROOT).rglob("*.py")) + observed = set(structure.scoped_test_paths(ROOT)) + assert expected + assert expected <= observed + + +def test_pol03a_skip_or_xfail_is_detected(tmp_path: Path) -> None: + """A focused compilation test cannot disable its proof.""" + path = tmp_path / structure.POL_03A_TEST_ROOT / "test_disabled.py" + _write(path, "import pytest\npytestmark = pytest.mark.skip\n") + assert path in structure.scoped_test_paths(tmp_path) + assert structure.weak_python(path) + + def test_inventory_records_one_oversized_production_function(tmp_path: Path) -> None: """A production callable beyond its hard limit enters the debt inventory.""" body = "\n".join(f" value_{index} = {index}" for index in range(101)) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 080fce28a..0cc19409f 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 = "f22478cb0230f14e7ffd7c4494e519b54daff046e19ca6dfde9fcf53dc99a144" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "50e35074611d76d676ebd87c370cc87b77bf0f56346b1a6c421a8676ca830595" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", @@ -83,6 +83,8 @@ "project_compensation_units", "project_create_idempotency_records", "project_guides", + "project_guide_compilation_attempts", + "project_guide_compilations", "project_role_grants", "project_role_qualification_snapshots", "project_setup_runs", @@ -118,6 +120,8 @@ "contribution_rules", "project_compensation_units", "project_create_idempotency_records", + "project_guide_compilation_attempts", + "project_guide_compilations", "project_role_grants", "project_role_qualification_snapshots", "review_admission_idempotency_records", diff --git a/backend/tests/projects/guide_compilation/__init__.py b/backend/tests/projects/guide_compilation/__init__.py new file mode 100644 index 000000000..540f61985 --- /dev/null +++ b/backend/tests/projects/guide_compilation/__init__.py @@ -0,0 +1 @@ +"""Focused hidden guide-compilation tests.""" diff --git a/backend/tests/projects/guide_compilation/helpers.py b/backend/tests/projects/guide_compilation/helpers.py new file mode 100644 index 000000000..b89cc6a94 --- /dev/null +++ b/backend/tests/projects/guide_compilation/helpers.py @@ -0,0 +1,302 @@ +"""Small canonical fixtures shared by guide-compilation behavior tests.""" + +from __future__ import annotations + +from dataclasses import replace +from uuid import UUID, uuid4 + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from app.db import models as _all_models # noqa: F401 +from app.interfaces.project_agents import ( + CompilationFinding, + GuideSourceMaterial, + ProjectGuideCompilationContext, + ProjectGuideCompilationResult, + SubmissionArtifactPolicyProposal, + VerifiedGuideMaterialSnapshot, +) +from app.modules.authorization.api import ( + ActorIdentityFacts, + ActorKind, + ProjectGuideCompilationExecutePersistFacts, + project_guide_compilation_execute_resource_digest, +) +from app.modules.checkers.catalogue import ( + build_pre_submission_checker_catalogue, + project_guide_pre_submission_capabilities, +) +from app.modules.projects.guide_compilation.contracts import ( + CompilationAttemptIdentity, + accepted_compilation_result, +) +from app.modules.projects.post_submit_policy import ( + project_guide_post_submission_capabilities, +) + +SHA256 = "sha256:" + "a" * 64 + + +def ids() -> dict[str, UUID]: + """Return complete unrelated identifiers for one test scenario.""" + return { + name: uuid4() + for name in ( + "actor", + "link", + "wrong_link", + "project", + "guide", + "snapshot", + "setup_1", + "setup_2", + "setup_3", + "operation", + "request", + "key", + "audit", + ) + } + + +def context(values: dict[str, UUID], *, generation: int = 1) -> ProjectGuideCompilationContext: + """Build one exact ART-verified compilation context.""" + material = GuideSourceMaterial( + project_id=str(values["project"]), + guide_id=str(values["guide"]), + guide_version="v1", + source_snapshot_id=str(values["snapshot"]), + source_snapshot_hash=SHA256, + guide_material={"content_markdown": "Canonical project guide."}, + verified_artifact_material=True, + source_items=[ + { + "source_kind": "uploaded_file", + "ingestion_adapter": "artifact_store", + "source_item_id": str(UUID("11111111-1111-1111-1111-111111111111")), + "extraction_usage_id": str(UUID("22222222-2222-2222-2222-222222222222")), + "canonical_output_sha256": SHA256, + } + ], + ) + return ProjectGuideCompilationContext( + material=VerifiedGuideMaterialSnapshot.from_material(material), + setup_run_id=values[f"setup_{generation}"], + setup_generation=generation, + instruction_version="v1", + agent_identity="project-guide-compilation-agent-v1", + pre_submission_capabilities=project_guide_pre_submission_capabilities( + build_pre_submission_checker_catalogue() + ), + post_submission_capabilities=project_guide_post_submission_capabilities(), + ) + + +def result() -> ProjectGuideCompilationResult: + """Return the smallest semantically valid unified result.""" + return ProjectGuideCompilationResult( + status="draft_ready", + findings=( + CompilationFinding( + severity="info", code="guide.ready", message="Guide is complete." + ), + ), + submission_artifact_policy=SubmissionArtifactPolicyProposal( + maximum_file_size_bytes=1_000, + maximum_package_size_bytes=10_000, + required_artifacts=("submission",), + ), + agent_version="v1", + ) + + +def identity( + compilation_context: ProjectGuideCompilationContext, +) -> CompilationAttemptIdentity: + """Derive the trusted attempt identity.""" + return CompilationAttemptIdentity.from_context(compilation_context, agent_version="v1") + + +def service_actor(values: dict[str, UUID]) -> ActorIdentityFacts: + """Return the only fixed service actor admitted by future execution.""" + return ActorIdentityFacts( + actor_profile_id=values["actor"], + identity_link_id=values["link"], + actor_kind=ActorKind.SERVICE, + service_identity="workstream.project.setup", + ) + + +def persistence_facts( + values: dict[str, UUID], + attempt_id: UUID, + attempt_identity: CompilationAttemptIdentity, + *, + predecessor_id: UUID | None = None, +) -> ProjectGuideCompilationExecutePersistFacts: + """Bind all public execution facts to one accepted result.""" + accepted = accepted_compilation_result(result()) + hashes = accepted.component_hashes + facts = ProjectGuideCompilationExecutePersistFacts( + project_id=attempt_identity.project_id, + guide_id=attempt_identity.guide_id, + guide_version=attempt_identity.guide_version, + source_snapshot_id=attempt_identity.source_snapshot_id, + source_snapshot_hash=attempt_identity.source_snapshot_hash, + canonical_input_hash=attempt_identity.canonical_input_hash, + guide_material_hash=attempt_identity.guide_material_hash, + setup_run_id=attempt_identity.setup_run_id, + setup_generation=attempt_identity.setup_generation, + operation_id=values["operation"], + request_id=values["request"], + idempotency_key=values["key"], + pre_catalogue_id=attempt_identity.pre_catalogue_id, + pre_catalogue_version=attempt_identity.pre_catalogue_version, + pre_catalogue_schema_version=attempt_identity.pre_catalogue_schema_version, + pre_catalogue_manifest_hash=attempt_identity.pre_catalogue_manifest_hash, + post_catalogue_id=attempt_identity.post_catalogue_id, + post_catalogue_version=attempt_identity.post_catalogue_version, + post_catalogue_schema_version=attempt_identity.post_catalogue_schema_version, + post_catalogue_manifest_hash=attempt_identity.post_catalogue_manifest_hash, + agent_identity=attempt_identity.agent_identity, + agent_version=attempt_identity.agent_version, + instruction_version=attempt_identity.instruction_version, + expected_predecessor_compilation_id=predecessor_id, + attempt_id=attempt_id, + provider_idempotency_key=attempt_identity.provider_idempotency_key(), + result_hash=accepted.result_hash, + sufficiency_component_hash=hashes.sufficiency_hash, + artifact_policy_component_hash=hashes.artifact_policy_hash, + requirement_inventory_component_hash=hashes.requirement_inventory_hash, + pre_submit_policy_component_hash=hashes.pre_submit_hash, + post_submit_policy_component_hash=hashes.post_submit_hash, + capability_suggestions_component_hash=hashes.capability_suggestions_hash, + setup_notes_component_hash=hashes.setup_notes_hash, + resource_context_digest=SHA256, + ) + return replace( + facts, + resource_context_digest=project_guide_compilation_execute_resource_digest( + service_actor(values), facts + ), + ) + + +async def seed_database(database_url: str, *, generations: int = 1) -> dict[str, UUID]: + """Seed only canonical parent rows needed by hidden persistence tests.""" + values = ids() + sql_values = {name: str(value) for name, value in values.items()} + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute( + text( + "insert into actor_profiles(id,actor_kind,status,provisioning_method," + "service_identity,created_by) values(:actor,'service','active'," + "'manual_service_provisioning','workstream.project.setup','test')" + ), + sql_values, + ) + await connection.execute( + text( + "insert into actor_identity_links(id,actor_profile_id,issuer,subject," + "subject_kind,status,linked_by) values(:link,:actor,'workstream-internal'," + "'workstream.project.setup','service','active','test')" + ), + sql_values, + ) + await connection.execute(text("alter table projects disable trigger user")) + await connection.execute( + text( + "insert into projects(id,name,slug,status) values" + "(:project,'Compilation project',:slug,'draft')" + ), + {**sql_values, "slug": f"compilation-{values['project']}"}, + ) + await connection.execute(text("alter table projects enable trigger user")) + for table in ( + "project_guides", + "guide_source_snapshots", + "project_setup_runs", + ): + await connection.execute(text(f"alter table {table} disable trigger user")) + await connection.execute( + text( + "insert into project_guides(id,project_id,version,status,content_markdown," + "created_by) values(:guide,:project,'v1','draft','# Guide','test')" + ), + sql_values, + ) + 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,'v1','guide_source_snapshot.v1','{}'::json," + ":hash,'test')" + ), + {**sql_values, "hash": SHA256}, + ) + for generation in range(1, generations + 1): + await connection.execute( + text( + "insert into project_setup_runs(id,project_id,guide_id,guide_version," + "source_snapshot_id,source_snapshot_hash,setup_generation,status," + "current_step,created_by) values(:setup,:project,:guide,'v1',:snapshot," + ":hash,:generation,'queued','guide_material_verified','test')" + ), + { + **sql_values, + "setup": str(values[f"setup_{generation}"]), + "hash": SHA256, + "generation": generation, + }, + ) + for table in reversed( + ("project_guides", "guide_source_snapshots", "project_setup_runs") + ): + await connection.execute(text(f"alter table {table} enable trigger user")) + finally: + await engine.dispose() + return values + + +async def insert_authorization_evidence( + database_url: str, + values: dict[str, UUID], + attempt_id: UUID, + *, + resource_context_digest: str, + action_id: str = "project.guide_compilation.execute", + permission_id: str = "project.guide_compilation.execute", +) -> UUID: + """Insert exact future execute evidence for one hidden persistence test.""" + event_id = uuid4() + sql_values = {name: str(value) for name, value in values.items()} | { + "audit": str(event_id), + "action": action_id, + "permission": permission_id, + "resource_digest": resource_context_digest, + } + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute( + text( + "insert into audit_events(id,entity_type,entity_id,event_type,actor_id," + "actor_roles,claim_snapshot,auth_source,is_dev_auth,event_payload," + "event_domain,event_version,actor_ref_kind,request_id,correlation_id," + "permission_id,action_id,reason,project_id,resource_type,resource_id," + "after_facts) values" + "(:audit,'authorization_decision',:audit,'SensitiveAuthorizationAllowed'," + ":actor,'[]'::json,'{}'::json,'local_authority',false,'{}'::json," + "'authority',1,'actor_profile',:request,:operation,:permission,:action," + "'authorization_evaluation',:project,'project_guide_compilation_attempt'," + ":attempt,jsonb_build_object('allowed',true," + "'resource_context_digest',cast(:resource_digest as text))::json)" + ), + {**sql_values, "attempt": str(attempt_id)}, + ) + finally: + await engine.dispose() + return event_id diff --git a/backend/tests/projects/guide_compilation/test_contracts.py b/backend/tests/projects/guide_compilation/test_contracts.py new file mode 100644 index 000000000..d9e21349b --- /dev/null +++ b/backend/tests/projects/guide_compilation/test_contracts.py @@ -0,0 +1,97 @@ +"""Canonical attempt and accepted-result contract behavior.""" + +from __future__ import annotations + +import re + +import pytest +from pydantic import ValidationError +from sqlalchemy.exc import IntegrityError + +from app.modules.projects.guide_compilation.contracts import ( + AcceptedCompilationResult, + accepted_compilation_result, + validate_accepted_compilation_result, +) +from app.modules.projects.guide_compilation.models import ProjectGuideCompilationAttempt +from app.modules.projects.guide_compilation.repository import ( + GuideCompilationConcurrencyError, + GuideCompilationStorageError, + _persistence_error, +) +from app.modules.projects.guide_compilation.validation import TERMINAL_FAILURE_CODES + +from .helpers import context, identity, ids, result + + +def test_attempt_provider_key_is_deterministic_and_context_bound() -> None: + """One exact logical attempt always produces one provider key.""" + values = ids() + first = identity(context(values)) + assert first.provider_idempotency_key() == identity(context(values)).provider_idempotency_key() + assert first.provider_idempotency_key() != first.model_copy( + update={"instruction_version": "v2"} + ).provider_idempotency_key() + + +def test_accepted_result_rejects_component_or_full_hash_drift() -> None: + """Durable provider output cannot be reconstructed with swapped hashes.""" + accepted = accepted_compilation_result(result()) + body = accepted.model_dump(mode="json") + body["component_hashes"]["sufficiency_hash"] = "sha256:" + "b" * 64 + with pytest.raises(ValidationError, match="hashes are invalid"): + AcceptedCompilationResult.model_validate(body) + body = accepted.model_dump(mode="json") + body["result_hash"] = "sha256:" + "b" * 64 + with pytest.raises(ValidationError, match="hashes are invalid"): + AcceptedCompilationResult.model_validate(body) + + +def test_accepted_result_revalidates_against_fresh_context() -> None: + """Stored output becomes unusable when its exact setup context drifts.""" + values = ids() + original = context(values) + attempt_identity = identity(original) + accepted = accepted_compilation_result(result()) + assert validate_accepted_compilation_result( + identity=attempt_identity, context=original, accepted=accepted + ) == result() + with pytest.raises(ValueError, match="context no longer matches"): + validate_accepted_compilation_result( + identity=attempt_identity, + context=original.model_copy(update={"setup_generation": 2}), + accepted=accepted, + ) + + +def test_terminal_failure_allowlist_matches_database_constraint() -> None: + """Application and database accept the same closed terminal failure codes.""" + constraint = next( + item + for item in ProjectGuideCompilationAttempt.__table__.constraints + if item.name and item.name.endswith("ck_compilation_attempt_state_shape") + ) + definition = str(constraint.sqltext) + allowlist = re.search(r"failure_code in \(([^)]+)\)", definition) + assert allowlist is not None + observed = set(re.findall(r"'([^']+)'", allowlist.group(1))) + assert observed == TERMINAL_FAILURE_CODES + + +@pytest.mark.parametrize( + ("constraint_name", "expected"), + [ + ("uq_project_guide_compilation_predecessor", GuideCompilationConcurrencyError), + ("uq_project_guide_compilation_root", GuideCompilationConcurrencyError), + ("ck_project_guide_compilation_custody", GuideCompilationStorageError), + ], +) +def test_database_failures_have_deterministic_domain_classification( + constraint_name: str, + expected: type[GuideCompilationConcurrencyError | GuideCompilationStorageError], +) -> None: + """Known lineage races remain distinct from other storage failures.""" + original = RuntimeError("database failure") + original.constraint_name = constraint_name # type: ignore[attr-defined] + error = IntegrityError("statement", {}, original) + assert isinstance(_persistence_error(error), expected) diff --git a/backend/tests/projects/guide_compilation/test_database_guards.py b/backend/tests/projects/guide_compilation/test_database_guards.py new file mode 100644 index 000000000..6c43a8316 --- /dev/null +++ b/backend/tests/projects/guide_compilation/test_database_guards.py @@ -0,0 +1,182 @@ +"""Direct PostgreSQL attack proof for compilation custody.""" + +from __future__ import annotations + +import json +import re + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.modules.projects.guide_compilation.repository import GuideCompilationRepository +from app.modules.projects.guide_compilation.validation import TERMINAL_FAILURE_CODES + +from .helpers import context, identity, seed_database + + +async def _reserved(database_url: str): + values = await seed_database(database_url) + engine = create_async_engine(database_url) + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as session, session.begin(): + _, attempt = await GuideCompilationRepository(session).reserve_attempt( + identity(context(values)) + ) + return engine, attempt + + +@pytest.mark.asyncio +async def test_invalid_terminal_rejects_an_accepted_timestamp( + clean_postgres_database: str, +) -> None: + """Terminal-invalid custody cannot look like accepted provider output.""" + engine, attempt = await _reserved(clean_postgres_database) + try: + async with engine.begin() as connection: + with pytest.raises(DBAPIError): + await connection.execute( + text( + "update project_guide_compilation_attempts set " + "status='compilation_invalid_terminal',failure_code='schema_invalid'," + "terminal_at=now(),accepted_at=now() where id=:id" + ), + {"id": attempt.id}, + ) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_attempt_identity_rejects_noncanonical_source_hash( + clean_postgres_database: str, +) -> None: + """PostgreSQL independently rejects malformed snapshot lineage hashes.""" + engine, attempt = await _reserved(clean_postgres_database) + try: + async with engine.begin() as connection: + with pytest.raises(DBAPIError): + await connection.execute( + text( + "update project_guide_compilation_attempts " + "set source_snapshot_hash=:hash where id=:id" + ), + {"id": attempt.id, "hash": "not-a-digest"}, + ) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_accepted_state_rejects_null_component_hashes( + clean_postgres_database: str, +) -> None: + """Explicit JSON nulls cannot satisfy accepted component custody.""" + engine, attempt = await _reserved(clean_postgres_database) + null_hashes = { + name: None + for name in ( + "sufficiency_hash", + "artifact_policy_hash", + "requirement_inventory_hash", + "pre_submit_hash", + "post_submit_hash", + "capability_suggestions_hash", + "setup_notes_hash", + ) + } + try: + async with engine.begin() as connection: + with pytest.raises(DBAPIError): + await connection.execute( + text( + "update project_guide_compilation_attempts set status='provider_result_accepted'," + "accepted_at=now(),canonical_result='{}'::json,result_hash=:hash," + "component_hashes=cast(:components as json) where id=:id" + ), + { + "id": attempt.id, + "hash": "sha256:" + "a" * 64, + "components": json.dumps(null_hashes), + }, + ) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_attempt_delete_is_rejected(clean_postgres_database: str) -> None: + """A reserved generation cannot disappear through row deletion.""" + engine, attempt = await _reserved(clean_postgres_database) + try: + async with engine.begin() as connection: + with pytest.raises(DBAPIError): + await connection.execute( + text("delete from project_guide_compilation_attempts where id=:id"), + {"id": attempt.id}, + ) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_attempt_truncate_is_rejected(clean_postgres_database: str) -> None: + """Bulk truncation cannot erase attempt custody.""" + engine, _ = await _reserved(clean_postgres_database) + try: + async with engine.begin() as connection: + with pytest.raises(DBAPIError): + await connection.execute( + text("truncate table project_guide_compilation_attempts") + ) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_live_terminal_constraint_matches_closed_vocabulary( + clean_postgres_database: str, +) -> None: + """Migrated PostgreSQL accepts exact reason codes and rejects legacy states.""" + engine, attempt = await _reserved(clean_postgres_database) + try: + async with engine.connect() as connection: + definition = await connection.scalar( + text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='project_guide_compilation_attempts'::regclass " + "and contype='c' and pg_get_constraintdef(oid) like '%failure_code%'" + ) + ) + assert definition is not None + allowlist = re.search(r"failure_code.*?ARRAY\[(.*?)\]", definition) + assert allowlist is not None + assert set(re.findall(r"'([^']+)'", allowlist.group(1))) == ( + TERMINAL_FAILURE_CODES + ) + assert "'accepted'" not in definition + assert "'invalid_terminal'" not in definition + + for status in ("accepted", "invalid_terminal", "persisted"): + with pytest.raises(DBAPIError): + async with engine.begin() as connection: + await connection.execute( + text( + "update project_guide_compilation_attempts " + "set status=:status where id=:id" + ), + {"id": attempt.id, "status": status}, + ) + with pytest.raises(DBAPIError): + async with engine.begin() as connection: + await connection.execute( + text( + "update project_guide_compilation_attempts set " + "status='compilation_invalid_terminal',terminal_at=now()," + "failure_code='not_allowlisted' where id=:id" + ), + {"id": attempt.id}, + ) + finally: + await engine.dispose() diff --git a/backend/tests/projects/guide_compilation/test_migration_contract.py b/backend/tests/projects/guide_compilation/test_migration_contract.py new file mode 100644 index 000000000..1583f6e4c --- /dev/null +++ b/backend/tests/projects/guide_compilation/test_migration_contract.py @@ -0,0 +1,111 @@ +"""Alembic topology and downgrade custody for migration 0062.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from alembic import command +from alembic.config import Config +import asyncpg +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.modules.projects.guide_compilation.repository import GuideCompilationRepository + +from .helpers import context, identity, seed_database + + +def _config() -> Config: + root = Path(__file__).resolve().parents[3] + config = Config(str(root / "alembic.ini")) + config.set_main_option("script_location", str(root / "alembic")) + return config + + +async def _schema_state(database_url: str) -> tuple[str, bool, int, int, int, int]: + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + head = await connection.fetchval("select version_num from alembic_version") + tables = await connection.fetchval( + "select to_regclass('project_guide_compilation_attempts') is not null" + ) + triggers = await connection.fetchval( + "select count(*) from pg_trigger where not tgisinternal and tgrelid in " + "(select c.oid from pg_class c join pg_namespace n on n.oid=c.relnamespace " + "where n.nspname='public' and c.relname in " + "('project_guide_compilation_attempts','project_guide_compilations'))" + ) + action_pairs = await connection.fetchval( + "select count(*) from pg_constraint where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authorization_action_evidence' and " + "pg_get_constraintdef(oid) like " + "'%project.guide_compilation.execute%'" + ) + permission_pairs = await connection.fetchval( + "select count(*) from pg_constraint where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authority_registries' and " + "pg_get_constraintdef(oid) like '%project.guide_compilation.execute%'" + ) + resource_types = await connection.fetchval( + "select count(*) from pg_constraint where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authority_privacy_bounds' and " + "pg_get_constraintdef(oid) like '%project_guide_compilation_attempt%'" + ) + return head, tables, triggers, action_pairs, permission_pairs, resource_types + finally: + await connection.close() + + +def test_0062_empty_round_trip_restores_exact_hidden_schema( + isolated_database_env: str, migration_lock +) -> None: + """An empty 0062 downgrade/re-upgrade restores its tables and four guards.""" + config = _config() + with migration_lock(): + try: + command.downgrade(config, "0061_submission_admission") + assert asyncio.run(_schema_state(isolated_database_env)) == ( + "0061_submission_admission", + False, + 0, + 0, + 0, + 0, + ) + finally: + command.upgrade(config, "0062_guide_compilation") + assert asyncio.run(_schema_state(isolated_database_env)) == ( + "0062_guide_compilation", + True, + 4, + 1, + 1, + 1, + ) + + +def test_0062_nonempty_attempt_blocks_downgrade( + isolated_database_env: str, migration_lock +) -> None: + """A consumed setup generation cannot disappear through downgrade.""" + async def seed_attempt() -> None: + values = await seed_database(isolated_database_env) + engine = create_async_engine(isolated_database_env) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + async with factory() as session, session.begin(): + await GuideCompilationRepository(session).reserve_attempt( + identity(context(values)) + ) + finally: + await engine.dispose() + + asyncio.run(seed_attempt()) + with migration_lock(), pytest.raises( + RuntimeError, match="cannot downgrade non-empty guide-compilation custody" + ): + command.downgrade(_config(), "0061_submission_admission") + assert asyncio.run(_schema_state(isolated_database_env))[0] == ( + "0062_guide_compilation" + ) diff --git a/backend/tests/projects/guide_compilation/test_public_authorization.py b/backend/tests/projects/guide_compilation/test_public_authorization.py new file mode 100644 index 000000000..56d626fcb --- /dev/null +++ b/backend/tests/projects/guide_compilation/test_public_authorization.py @@ -0,0 +1,83 @@ +"""Public fact integrity and inactive authorization behavior.""" + +from __future__ import annotations + +from dataclasses import replace +from uuid import uuid4 + +import pytest + +from app.modules.authorization.api import ( + AuthorizationUnavailable, + ProjectGuideCompilationRequestFacts, +) +from app.modules.projects.guide_compilation.authorization import ( + DenyProjectGuideCompilationAuthorization, +) + +from .helpers import SHA256, context, identity, ids, persistence_facts, service_actor + + +def _request_facts() -> ProjectGuideCompilationRequestFacts: + values = ids() + attempt_identity = identity(context(values)) + persist = persistence_facts(values, uuid4(), attempt_identity) + return ProjectGuideCompilationRequestFacts( + operation_id=persist.operation_id, + request_id=persist.request_id, + idempotency_key=persist.idempotency_key, + project_id=persist.project_id, + guide_id=persist.guide_id, + guide_version=persist.guide_version, + source_snapshot_id=persist.source_snapshot_id, + source_snapshot_hash=persist.source_snapshot_hash, + setup_run_id=persist.setup_run_id, + setup_generation=persist.setup_generation, + canonical_input_hash=persist.canonical_input_hash, + guide_material_hash=persist.guide_material_hash, + pre_catalogue_id=persist.pre_catalogue_id, + pre_catalogue_version=persist.pre_catalogue_version, + pre_catalogue_schema_version=persist.pre_catalogue_schema_version, + pre_catalogue_manifest_hash=persist.pre_catalogue_manifest_hash, + post_catalogue_id=persist.post_catalogue_id, + post_catalogue_version=persist.post_catalogue_version, + post_catalogue_schema_version=persist.post_catalogue_schema_version, + post_catalogue_manifest_hash=persist.post_catalogue_manifest_hash, + agent_identity=persist.agent_identity, + agent_version=persist.agent_version, + instruction_version=persist.instruction_version, + ) + + +def test_public_facts_reject_wrong_uuid_and_unbounded_token() -> None: + """Public AUTH facts reject open scalar shapes before evaluation.""" + facts = _request_facts() + with pytest.raises(ValueError, match="project_id must be a UUID"): + replace(facts, project_id="not-a-uuid") # type: ignore[arg-type] + with pytest.raises(ValueError, match="agent_version"): + replace(facts, agent_version="x" * 161) + with pytest.raises(ValueError, match="source_snapshot_hash"): + replace(facts, source_snapshot_hash=SHA256.upper()) + + +@pytest.mark.asyncio +async def test_hidden_authorization_denies_before_touching_product_state() -> None: + """Every request and execute operation remains unavailable in 03A.""" + values = ids() + compilation_context = context(values) + attempt_identity = identity(compilation_context) + facts = persistence_facts(values, uuid4(), attempt_identity) + actor = service_actor(values) + denial = DenyProjectGuideCompilationAuthorization() + request_facts = _request_facts() + + with pytest.raises(AuthorizationUnavailable): + await denial.prepare_request(actor=actor, facts=request_facts) + with pytest.raises(AuthorizationUnavailable): + await denial.consume_request(handle=object(), actor=actor, facts=request_facts) + with pytest.raises(AuthorizationUnavailable): + await denial.authorize_execute_preflight(actor=actor, facts=facts) + with pytest.raises(AuthorizationUnavailable): + await denial.prepare_execute_persist(actor=actor, facts=facts) + with pytest.raises(AuthorizationUnavailable): + await denial.consume_execute_persist(handle=object(), actor=actor, facts=facts) diff --git a/backend/tests/projects/guide_compilation/test_repository_attempts.py b/backend/tests/projects/guide_compilation/test_repository_attempts.py new file mode 100644 index 000000000..19631c3cc --- /dev/null +++ b/backend/tests/projects/guide_compilation/test_repository_attempts.py @@ -0,0 +1,98 @@ +"""PostgreSQL attempt reservation and terminal-state behavior.""" + +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.modules.projects.guide_compilation.repository import ( + GuideCompilationIntegrityError, + GuideCompilationRepository, +) + +from .helpers import context, identity, seed_database + + +@pytest.mark.asyncio +async def test_concurrent_reservation_converges_on_one_key( + clean_postgres_database: str, +) -> None: + """Concurrent exact claims converge on one durable provider key.""" + values = await seed_database(clean_postgres_database) + attempt_identity = identity(context(values)) + engine = create_async_engine(clean_postgres_database) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + async def reserve(): + async with factory() as session, session.begin(): + return await GuideCompilationRepository(session).reserve_attempt( + attempt_identity + ) + + first, second = await asyncio.gather(reserve(), reserve()) + assert {first[0], second[0]} == {"claimed", "existing"} + assert first[1].id == second[1].id + assert first[1].provider_idempotency_key == attempt_identity.provider_idempotency_key() + + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_reservation_identity_mismatch_reuses_no_key( + clean_postgres_database: str, +) -> None: + """Identity drift cannot allocate a second provider key for the generation.""" + values = await seed_database(clean_postgres_database) + attempt_identity = identity(context(values)) + engine = create_async_engine(clean_postgres_database) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + async with factory() as session, session.begin(): + _, original = await GuideCompilationRepository(session).reserve_attempt( + attempt_identity + ) + changed = attempt_identity.model_copy(update={"instruction_version": "v2"}) + async with factory() as session, session.begin(): + outcome, preserved = await GuideCompilationRepository(session).reserve_attempt( + changed + ) + assert outcome == "mismatch" + assert preserved.id == original.id + assert preserved.provider_idempotency_key == original.provider_idempotency_key + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_uncertain_to_invalid_terminal_preserves_one_attempt( + clean_postgres_database: str, +) -> None: + """Uncertain execution can terminate but can never allocate a retry key.""" + values = await seed_database(clean_postgres_database) + attempt_identity = identity(context(values)) + engine = create_async_engine(clean_postgres_database) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + async with factory() as session, session.begin(): + _, attempt = await GuideCompilationRepository(session).reserve_attempt( + attempt_identity + ) + key = attempt.provider_idempotency_key + async with factory() as session, session.begin(): + uncertain = await GuideCompilationRepository(session).mark_provider_uncertain( + attempt.id + ) + assert uncertain.provider_idempotency_key == key + async with factory() as session, session.begin(): + terminal = await GuideCompilationRepository(session).mark_invalid_terminal( + attempt_id=attempt.id, failure_code="schema_invalid" + ) + assert terminal.status == "compilation_invalid_terminal" + async with factory() as session, session.begin(): + with pytest.raises(GuideCompilationIntegrityError): + await GuideCompilationRepository(session).mark_provider_uncertain(attempt.id) + finally: + await engine.dispose() diff --git a/backend/tests/projects/guide_compilation/test_repository_persistence.py b/backend/tests/projects/guide_compilation/test_repository_persistence.py new file mode 100644 index 000000000..38956dc71 --- /dev/null +++ b/backend/tests/projects/guide_compilation/test_repository_persistence.py @@ -0,0 +1,466 @@ +"""PostgreSQL accepted-result recovery and append-only behavior.""" + +from __future__ import annotations + +import asyncio +from dataclasses import replace + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.modules.projects.guide_compilation.repository import ( + GuideCompilationConcurrencyError, + GuideCompilationIntegrityError, + GuideCompilationRepository, + GuideCompilationStorageError, +) + +from .helpers import ( + context, + identity, + insert_authorization_evidence, + persistence_facts, + result, + seed_database, + service_actor, +) + + +async def _accepted_attempt(factory, values, *, generation: int = 1): + compilation_context = context(values, generation=generation) + attempt_identity = identity(compilation_context) + async with factory() as session, session.begin(): + repository = GuideCompilationRepository(session) + _, attempt = await repository.reserve_attempt(attempt_identity) + await repository.accept_result( + attempt_id=attempt.id, context=compilation_context, result=result() + ) + return attempt, attempt_identity, compilation_context + + +async def _persisted_root(factory, database_url: str, values): + attempt, attempt_identity, compilation_context = await _accepted_attempt(factory, values) + facts = persistence_facts(values, attempt.id, attempt_identity) + decision_id = await insert_authorization_evidence( + database_url, + values, + attempt.id, + resource_context_digest=facts.resource_context_digest, + ) + async with factory() as session, session.begin(): + return await GuideCompilationRepository(session).persist_accepted( + attempt_id=attempt.id, + context=compilation_context, + expected_predecessor_id=None, + actor=service_actor(values), + facts=facts, + authorization_decision_event_id=decision_id, + ) + + +@pytest.mark.asyncio +async def test_accepted_crash_recovery_persists_exactly_once( + clean_postgres_database: str, +) -> None: + """Accepted custody survives a transaction boundary and replay converges.""" + values = await seed_database(clean_postgres_database) + engine = create_async_engine(clean_postgres_database) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + attempt, attempt_identity, compilation_context = await _accepted_attempt( + factory, values + ) + facts = persistence_facts(values, attempt.id, attempt_identity) + decision_id = await insert_authorization_evidence( + clean_postgres_database, + values, + attempt.id, + resource_context_digest=facts.resource_context_digest, + ) + async with factory() as session, session.begin(): + first = await GuideCompilationRepository(session).persist_accepted( + attempt_id=attempt.id, + context=compilation_context, + expected_predecessor_id=None, + actor=service_actor(values), + facts=facts, + authorization_decision_event_id=decision_id, + ) + async with factory() as session, session.begin(): + replay = await GuideCompilationRepository(session).persist_accepted( + attempt_id=attempt.id, + context=compilation_context, + expected_predecessor_id=None, + actor=service_actor(values), + facts=facts, + authorization_decision_event_id=decision_id, + ) + assert replay.id == first.id + assert replay.attempt_id == attempt.id + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_concurrent_recovery_persists_one_compilation( + clean_postgres_database: str, +) -> None: + """Two recovery workers converge on one immutable business effect.""" + values = await seed_database(clean_postgres_database) + engine = create_async_engine(clean_postgres_database) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + attempt, attempt_identity, compilation_context = await _accepted_attempt( + factory, values + ) + facts = persistence_facts(values, attempt.id, attempt_identity) + decision_id = await insert_authorization_evidence( + clean_postgres_database, + values, + attempt.id, + resource_context_digest=facts.resource_context_digest, + ) + + async def persist(): + async with factory() as session, session.begin(): + return await GuideCompilationRepository(session).persist_accepted( + attempt_id=attempt.id, + context=compilation_context, + expected_predecessor_id=None, + actor=service_actor(values), + facts=facts, + authorization_decision_event_id=decision_id, + ) + + first, second = await asyncio.gather(persist(), persist()) + assert first.id == second.id + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_wrong_resource_authority_leaves_accepted_attempt_unpersisted( + clean_postgres_database: str, +) -> None: + """A mismatched prepared fact cannot cross the durable boundary.""" + values = await seed_database(clean_postgres_database) + engine = create_async_engine(clean_postgres_database) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + attempt, attempt_identity, compilation_context = await _accepted_attempt( + factory, values + ) + facts = replace( + persistence_facts(values, attempt.id, attempt_identity), + guide_material_hash="sha256:" + "b" * 64, + ) + decision_id = await insert_authorization_evidence( + clean_postgres_database, + values, + attempt.id, + resource_context_digest=facts.resource_context_digest, + ) + async with factory() as session, session.begin(): + with pytest.raises( + GuideCompilationIntegrityError, + match="accepted compilation custody is invalid", + ): + await GuideCompilationRepository(session).persist_accepted( + attempt_id=attempt.id, + context=compilation_context, + expected_predecessor_id=None, + actor=service_actor(values), + facts=facts, + authorization_decision_event_id=decision_id, + ) + async with factory() as session: + classification = await GuideCompilationRepository( + session + ).recovery_classification(attempt.id) + assert classification == "provider_result_accepted_not_persisted" + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_unrelated_authority_event_cannot_create_compilation( + clean_postgres_database: str, +) -> None: + """PostgreSQL rejects a valid audit event borrowed from another action.""" + values = await seed_database(clean_postgres_database) + engine = create_async_engine(clean_postgres_database) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + attempt, attempt_identity, compilation_context = await _accepted_attempt( + factory, values + ) + facts = persistence_facts(values, attempt.id, attempt_identity) + decision_id = await insert_authorization_evidence( + clean_postgres_database, + values, + attempt.id, + action_id="project.guide_sufficiency.run", + permission_id="project.guide.manage", + resource_context_digest=facts.resource_context_digest, + ) + async with factory() as session, session.begin(): + with pytest.raises( + GuideCompilationStorageError, + match="failed before durable custody", + ) as caught: + await GuideCompilationRepository(session).persist_accepted( + attempt_id=attempt.id, + context=compilation_context, + expected_predecessor_id=None, + actor=service_actor(values), + facts=facts, + authorization_decision_event_id=decision_id, + ) + assert "authorization evidence is invalid" in str(caught.value.__cause__) + async with factory() as session: + assert await session.scalar( + text( + "select count(*) from project_guide_compilations " + "where attempt_id=:attempt_id" + ), + {"attempt_id": attempt.id}, + ) == 0 + assert await GuideCompilationRepository( + session + ).recovery_classification(attempt.id) == ( + "provider_result_accepted_not_persisted" + ) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_wrong_authority_digest_cannot_create_compilation( + clean_postgres_database: str, +) -> None: + """Same-action evidence for a different final context fails closed.""" + values = await seed_database(clean_postgres_database) + engine = create_async_engine(clean_postgres_database) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + attempt, attempt_identity, compilation_context = await _accepted_attempt( + factory, values + ) + facts = persistence_facts(values, attempt.id, attempt_identity) + decision_id = await insert_authorization_evidence( + clean_postgres_database, + values, + attempt.id, + resource_context_digest="sha256:" + "b" * 64, + ) + async with factory() as session, session.begin(): + with pytest.raises( + GuideCompilationStorageError, + match="failed before durable custody", + ) as caught: + await GuideCompilationRepository(session).persist_accepted( + attempt_id=attempt.id, + context=compilation_context, + expected_predecessor_id=None, + actor=service_actor(values), + facts=facts, + authorization_decision_event_id=decision_id, + ) + assert "authorization evidence is invalid" in str(caught.value.__cause__) + async with factory() as session: + assert await session.scalar( + text( + "select count(*) from project_guide_compilations " + "where attempt_id=:attempt_id" + ), + {"attempt_id": attempt.id}, + ) == 0 + assert await GuideCompilationRepository( + session + ).recovery_classification(attempt.id) == ( + "provider_result_accepted_not_persisted" + ) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_fixed_service_profile_cannot_gain_a_second_wrong_link( + clean_postgres_database: str, +) -> None: + """Database identity custody prevents same-profile link substitution.""" + values = await seed_database(clean_postgres_database) + engine = create_async_engine(clean_postgres_database) + try: + async with engine.begin() as connection: + with pytest.raises(DBAPIError): + await connection.execute( + text( + "insert into actor_identity_links(id,actor_profile_id,issuer,subject," + "subject_kind,status,linked_by) values(:id,:actor," + "'workstream-internal','wrong.service','service','active','test')" + ), + {"id": str(values["wrong_link"]), "actor": str(values["actor"])}, + ) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_compilation_delete_is_rejected(clean_postgres_database: str) -> None: + """An immutable compilation cannot disappear through row deletion.""" + values = await seed_database(clean_postgres_database) + engine = create_async_engine(clean_postgres_database) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + compilation = await _persisted_root(factory, clean_postgres_database, values) + async with engine.begin() as connection: + with pytest.raises(DBAPIError): + await connection.execute( + text("delete from project_guide_compilations where id=:id"), + {"id": compilation.id}, + ) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_compilation_truncate_is_rejected(clean_postgres_database: str) -> None: + """Bulk truncation cannot erase immutable compilation custody.""" + values = await seed_database(clean_postgres_database) + engine = create_async_engine(clean_postgres_database) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + await _persisted_root(factory, clean_postgres_database, values) + async with engine.begin() as connection: + with pytest.raises(DBAPIError): + await connection.execute(text("truncate table project_guide_compilations")) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_concurrent_child_fork_allows_exactly_one_successor( + clean_postgres_database: str, +) -> None: + """Two later generations racing on one predecessor cannot fork the graph.""" + values = await seed_database(clean_postgres_database, generations=3) + engine = create_async_engine(clean_postgres_database) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + root = await _persisted_root(factory, clean_postgres_database, values) + candidates = [] + for generation in (2, 3): + attempt, attempt_identity, compilation_context = await _accepted_attempt( + factory, values, generation=generation + ) + facts = persistence_facts( + values, + attempt.id, + attempt_identity, + predecessor_id=root.id, + ) + decision_id = await insert_authorization_evidence( + clean_postgres_database, + values, + attempt.id, + resource_context_digest=facts.resource_context_digest, + ) + candidates.append((attempt, compilation_context, facts, decision_id)) + + async def persist(candidate): + attempt, compilation_context, facts, decision_id = candidate + async with factory() as session, session.begin(): + return await GuideCompilationRepository(session).persist_accepted( + attempt_id=attempt.id, + context=compilation_context, + expected_predecessor_id=root.id, + actor=service_actor(values), + facts=facts, + authorization_decision_event_id=decision_id, + ) + + outcomes = await asyncio.gather( + *(persist(candidate) for candidate in candidates), return_exceptions=True + ) + successes = [value for value in outcomes if not isinstance(value, Exception)] + failures = [value for value in outcomes if isinstance(value, Exception)] + assert len(successes) == 1 + assert len(failures) == 1 + assert isinstance(failures[0], GuideCompilationConcurrencyError) + assert "reload the lineage tip and retry" in str(failures[0]) + assert successes[0].supersedes_compilation_id == root.id + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_stale_predecessor_fails_closed( + clean_postgres_database: str, +) -> None: + """A later generation cannot persist without naming the current predecessor.""" + values = await seed_database(clean_postgres_database, generations=2) + engine = create_async_engine(clean_postgres_database) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + first_attempt, first_identity, first_context = await _accepted_attempt(factory, values) + first_facts = persistence_facts(values, first_attempt.id, first_identity) + first_decision = await insert_authorization_evidence( + clean_postgres_database, + values, + first_attempt.id, + resource_context_digest=first_facts.resource_context_digest, + ) + async with factory() as session, session.begin(): + await GuideCompilationRepository(session).persist_accepted( + attempt_id=first_attempt.id, + context=first_context, + expected_predecessor_id=None, + actor=service_actor(values), + facts=first_facts, + authorization_decision_event_id=first_decision, + ) + second_attempt, second_identity, second_context = await _accepted_attempt( + factory, values, generation=2 + ) + second_facts = persistence_facts(values, second_attempt.id, second_identity) + second_decision = await insert_authorization_evidence( + clean_postgres_database, + values, + second_attempt.id, + resource_context_digest=second_facts.resource_context_digest, + ) + async with factory() as session, session.begin(): + with pytest.raises(GuideCompilationIntegrityError, match="predecessor is stale"): + await GuideCompilationRepository(session).persist_accepted( + attempt_id=second_attempt.id, + context=second_context, + expected_predecessor_id=None, + actor=service_actor(values), + facts=second_facts, + authorization_decision_event_id=second_decision, + ) + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_compilation_update_is_rejected(clean_postgres_database: str) -> None: + """An immutable compilation cannot be edited after insertion.""" + values = await seed_database(clean_postgres_database) + engine = create_async_engine(clean_postgres_database) + factory = async_sessionmaker(engine, expire_on_commit=False) + try: + compilation = await _persisted_root(factory, clean_postgres_database, values) + async with factory() as session: + with pytest.raises(DBAPIError): + await session.execute( + text( + "update project_guide_compilations set agent_version='v2' where id=:id" + ), + {"id": compilation.id}, + ) + finally: + await engine.dispose() diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 70ae7fa9c..d3df0d37b 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -73,7 +73,7 @@ snapshot_existing_service_rows, ) -HEAD_REVISION = "0061_submission_admission" +HEAD_REVISION = "0062_guide_compilation" pytestmark = pytest.mark.postgres_schema_contract @@ -13543,25 +13543,20 @@ def test_xint003_02b_policy_authority_schema_and_roundtrip( _XINT003_02C_ACTIONS = ( ("review.revision_context.repair", "project.task.manage"), ("review.revision_obligation.close", "project.task.manage"), - ("review.revision_context.legacy_close", "operations.reconcile.run"), - ("review.lifecycle.activation.manage", "operations.reconcile.run"), + ("review.revision_context.legacy_close", "operations.reconcile.run"), ("review.lifecycle.activation.manage", "operations.reconcile.run"), ) _XINT003_02C_IDENTITIES = tuple( identity.value for identity in ( - ServiceIdentity.REVIEW_PREFERENCE_EXPIRY, - ServiceIdentity.REVIEW_LEASE_EXPIRY, - ServiceIdentity.REVIEW_AUTHORITY_INVALIDATION_RECONCILIATION, - ServiceIdentity.REVIEW_RECONCILIATION, - ServiceIdentity.REVIEW_ARTIFACT_REFERENCE_RECONCILIATION, - ServiceIdentity.REVIEW_PROJECTION, + ServiceIdentity.REVIEW_PREFERENCE_EXPIRY, ServiceIdentity.REVIEW_LEASE_EXPIRY, + ServiceIdentity.REVIEW_AUTHORITY_INVALIDATION_RECONCILIATION, ServiceIdentity.REVIEW_RECONCILIATION, + ServiceIdentity.REVIEW_ARTIFACT_REFERENCE_RECONCILIATION, ServiceIdentity.REVIEW_PROJECTION, ) ) def test_xint003_02c_rev_auth_readiness_schema_and_roundtrip( - isolated_database_env: str, migration_lock -) -> None: + isolated_database_env: str, migration_lock) -> None: """0049 admits exact planned evidence and principals without seeding authority.""" config = _alembic_config() with migration_lock(): @@ -13577,12 +13572,14 @@ def test_xint003_02c_rev_auth_readiness_schema_and_roundtrip( finally: command.upgrade(config, "head") - additions = " OR " + " OR ".join( - _xint003_02c_pair_token(action, permission) for action, permission in _XINT003_02C_ACTIONS - ) + additions = " OR " + " OR ".join(_xint003_02c_pair_token(*pair) for pair in _XINT003_02C_ACTIONS) + compilation_addition = " OR " + _xint003_02c_pair_token( + "project.guide_compilation.execute", "project.guide_compilation.execute") assert prior["profiles"] == upgraded["profiles"] == 0 assert upgraded["action_definition"].count(additions) == 2 - assert upgraded["action_definition"].replace(additions, "") == prior["action_definition"] + assert upgraded["action_definition"].count(compilation_addition) == 2 + assert upgraded["action_definition"].replace(additions, "").replace( + compilation_addition, "") == prior["action_definition"] historical_identities = (*FROZEN_SERVICE_IDENTITY_VALUES, ServiceIdentity.PROJECT_SETUP.value) assert prior["identity_values"] == historical_identities assert upgraded["identity_values"] == (*historical_identities, *_XINT003_02C_IDENTITIES) @@ -14072,8 +14069,7 @@ async def _remove_xint003_02a_immutable_policies(database_url: str, ids: dict[st if has_lineage: await connection.execute( text( - "update project_guides set status='draft',selected_review_policy_id=null," - "selected_review_policy_generation=null,selected_review_policy_hash=null," + "update project_guides set status='draft',selected_review_policy_id=null,selected_review_policy_generation=null,selected_review_policy_hash=null," "selected_revision_policy_id=null," "selected_revision_policy_generation=null," "selected_revision_policy_hash=null where id=:guide" diff --git a/backend/tests/test_behavior_ownership.py b/backend/tests/test_behavior_ownership.py index 87cc94814..515dfea62 100644 --- a/backend/tests/test_behavior_ownership.py +++ b/backend/tests/test_behavior_ownership.py @@ -369,6 +369,49 @@ def test_approved_foundation_target_cannot_remain_unresolved( ownership.validate_catalogue() +def test_pol03a_targets_are_narrow_and_keep_declarative_model_unresolved() -> None: + """POL-03A admits only callable ownership while model metadata stays unresolved.""" + assert ownership.POL_03A_CALLABLE_TARGETS == { + "backend/app/modules/authorization/api/project_guide_compilation.py", + "backend/app/modules/projects/guide_compilation/authorization.py", + "backend/app/modules/projects/guide_compilation/contracts.py", + "backend/app/modules/projects/guide_compilation/repository.py", + "backend/app/modules/projects/guide_compilation/validation.py", + } + assert ownership.POL_03A_DECLARATIVE_MODEL_TARGET not in ( + ownership.POL_03A_CALLABLE_TARGETS + ) + + +def test_pol03a_partition_transition_accepts_only_declared_additions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + existing = "backend/scripts/existing.py" + additions = sorted( + ownership.POL_03A_CALLABLE_TARGETS + | {ownership.POL_03A_DECLARATIVE_MODEL_TARGET} + ) + trusted = _partition([existing]) + current = _partition(sorted([existing, *additions])) + monkeypatch.setattr(ownership, "AUTH_BOUNDARY_FOUNDATION_TARGETS", frozenset()) + ownership._validate_additive_partition_transition(current, trusted) + + +def test_pol03a_partition_transition_rejects_undeclared_target( + monkeypatch: pytest.MonkeyPatch, +) -> None: + existing = "backend/scripts/existing.py" + additions = sorted( + ownership.POL_03A_CALLABLE_TARGETS + | {ownership.POL_03A_DECLARATIVE_MODEL_TARGET} + ) + trusted = _partition([existing]) + current = _partition(sorted([existing, *additions, "backend/scripts/extra.py"])) + monkeypatch.setattr(ownership, "AUTH_BOUNDARY_FOUNDATION_TARGETS", frozenset()) + with pytest.raises(ownership.BehaviorOwnershipError, match="untrusted_partition_change"): + ownership._validate_additive_partition_transition(current, trusted) + + def test_changed_callable_parity_delegates_to_policy(monkeypatch: pytest.MonkeyPatch) -> None: captured: list[object] = [] diff --git a/backend/tests/test_ci_test_lanes.py b/backend/tests/test_ci_test_lanes.py index 016d42c08..5cc39b278 100644 --- a/backend/tests/test_ci_test_lanes.py +++ b/backend/tests/test_ci_test_lanes.py @@ -40,7 +40,15 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: """Keep lane balance tied to subsystem ownership and measured schema cost.""" modules_by_lane = {lane.name: set(lane.modules) for lane in LANES} - assert modules_by_lane["project_lifecycle"] == {"tests/test_projects.py"} + assert modules_by_lane["project_lifecycle"] == { + "tests/projects/guide_compilation/test_contracts.py", + "tests/projects/guide_compilation/test_database_guards.py", + "tests/projects/guide_compilation/test_migration_contract.py", + "tests/projects/guide_compilation/test_public_authorization.py", + "tests/projects/guide_compilation/test_repository_attempts.py", + "tests/projects/guide_compilation/test_repository_persistence.py", + "tests/test_projects.py", + } assert modules_by_lane["task_lifecycle"] == { "tests/test_checker_catalogue.py", "tests/test_checkers.py", diff --git a/docker-compose.yml b/docker-compose.yml index 08d45ad11..8452b558b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: postgres: - image: postgres:16 + image: public.ecr.aws/docker/library/postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 environment: POSTGRES_DB: workstream POSTGRES_USER: workstream