From 813dca52909392c091c1c879f05e601bf214e9d4 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 8 Aug 2026 11:54:40 +0100 Subject: [PATCH 1/4] Add unified guide compilation contracts --- .../DISCOVERY.md | 9 +- .../PLAN.md | 20 +- ...OL-003-01-contract-catalogue-projection.md | 193 +++++- .../WS-POL-003-01-internal-review-evidence.md | 41 ++ .../reviews/WS-POL-003-01-pr-trust-bundle.md | 53 ++ backend/app/interfaces/project_agents.py | 622 ++++++++++++++++- backend/app/modules/checkers/catalogue.py | 33 +- .../modules/projects/post_submit_policy.py | 42 +- ...est_project_guide_compilation_contracts.py | 641 ++++++++++++++++++ 9 files changed, 1605 insertions(+), 49 deletions(-) create mode 100644 .agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-01-internal-review-evidence.md create mode 100644 .agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-01-pr-trust-bundle.md create mode 100644 backend/tests/test_project_guide_compilation_contracts.py diff --git a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/DISCOVERY.md b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/DISCOVERY.md index c5b1efcb1..0337693c4 100644 --- a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/DISCOVERY.md +++ b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/DISCOVERY.md @@ -95,14 +95,15 @@ on 2026-08-08. setup generation changes. - Representative task material is optional bounded context. Guide setup must not depend on tasks already existing. -- Current post-submit compilation must gain a trusted hard rejection for - platform-default repetition; prompt instructions are insufficient. +- The new unified proposal validator must reject platform-default repetition; + POL-01 does not change historical post-submit compiler behavior. - A catalogue is not an execution API. The checker service must expose exactly one typed call per phase and accept no caller-selected checker names. -## Unknowns to resolve in the planning PR +## Remaining unknowns after POL-01 contract repair -- Final names and limits for evidence-reference and safe-text schemas. +- POL-01 freezes evidence-reference and safe-text names/limits in its active + executable contract; implementation must prove them before later chunks. - Exact AUTH action/resource binding for creation of the compilation record; use narrow XINT/AUTH compilation request+execute actions for the parent while preserving separate 12E/12F/12G projection actions. diff --git a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/PLAN.md b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/PLAN.md index a9390b976..eb7870c7a 100644 --- a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/PLAN.md +++ b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/PLAN.md @@ -133,15 +133,19 @@ boundaries without WS-POL-003 modifying ART code or forcing ART lifecycle change `ProjectGuideCompilationContext` is strict (`extra="forbid"`) and contains: -- exact existing ART-verified `GuideSourceMaterial`; +- a deeply immutable canonical snapshot of exact ART-verified + `GuideSourceMaterial`, its payload hash, and source evidence lineage; - optional bounded representative task context; -- non-selectable `platform_coverage` generated from ART-04B1 platform entries - plus CHECKER-owned durable post-submit defaults; -- selectable `project_capabilities` generated from ART-04B1's project-rule - namespace for pre-submit and CHECKER/POL's registered rules for post-submit; -- server-owned classification policy and schema versions; -- optional bounded correction feedback tied to an exact superseded - compilation. +- one exact `pre_submission_capabilities` projection generated from ART-04B1, + carrying both non-selectable platform entries and selectable project-policy + primitives; +- one exact `post_submission_capabilities` projection generated from + CHECKER-owned registration plus the frozen durable default snapshot; and +- server-owned setup run/generation, instruction version, and agent identity. + +Later persistence/correction chunks bind classification policy and bounded +correction feedback to an exact superseded compilation; POL-01 does not expose +those fields prematurely. Representative task context is tenant-local, server-redacted, and limited to policy shape. It excludes actor/user IDs, emails, submission artifacts, review diff --git a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-01-contract-catalogue-projection.md b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-01-contract-catalogue-projection.md index 8a69df4bb..80b1f059a 100644 --- a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-01-contract-catalogue-projection.md +++ b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-01-contract-catalogue-projection.md @@ -1,41 +1,174 @@ # Chunk Contract: WS-POL-003-01 - Unified Contract and Catalogue Projection -Status: Proposed, inactive. Risk: L1. +Status: Active after explicit human start on 2026-08-08. Risk: L1. ## Goal -Add strict unified input/result/evidence models and read-only projections from -ART-04B1's complete pre-submit catalogue and CHECKER/POL's durable post-submit -capability truth. No model call, persistence, registry, or lifecycle change. +Add strict bounded unified input/result/evidence contracts and two read-only +capability projections from existing phase-owner truth. No model call, +persistence, registry, compiler behavior, composition-root, or lifecycle +change is permitted. + +## Why this chunk exists + +The later unified adapter needs one closed model-facing contract without +copying ART's 26-entry catalogue or treating CHECKER's runtime registry as a +second POL-owned registry. ## Allowed files -`backend/app/interfaces/project_agents.py`, canonical ART and CHECKER/POL -projection interfaces/composition only, focused tests, and WS-POL-003 docs. +```text +backend/app/interfaces/project_agents.py +backend/app/modules/checkers/catalogue.py +backend/app/modules/projects/post_submit_policy.py +backend/tests/test_project_guide_compilation_contracts.py +.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/** +``` + +`catalogue.py` and `post_submit_policy.py` may add pure read-only projection +functions only. Their existing registry, definitions, defaults, compiler, +validation, hashing, and execution semantics are frozen. ## Not allowed -ART/CHECKER catalogue changes, duplicate project registry, database/model/Celery -changes, action activation, or checker execution. - -## Acceptance - -- Strict bounded schemas reject extra/executable/unsafe fields. -- ART platform coverage is non-selectable. Pre-submit project capabilities come - only from ART-04B1; post-submit project capabilities come only from the - canonical durable CHECKER/POL source. -- Pre-submit input preserves the exact full 26-entry ART-04B1 manifest, - including enabled and disabled advisory entries, catalogue ID/version/schema, - `manifest_sha256`, definition identities and states, dispatch kinds, - classifications, phases, policy fields, resource budgets, and disabled - behavior. Selection remains limited to enabled policy primitives. POL invents - no missing timeout/safety fields and cannot mutate catalogue state. -- `GuideEvidenceRef` is closed and raw excerpts/URLs/paths cannot enter it. -- Optional representative task context does not gate compilation. -- Unknown/default/wrong-stage bindings fail closed. - -## Verification and review - -Focused schema/catalogue tests, Ruff, type checks, stale-registry scan. Required -reviewers: architecture, security, QA, product, reuse, test delta, CI integrity. -Human focus: no second registry and no executable model fields. +```text +new or changed ART/CHECKER catalogue definitions/defaults/registrations +new registry, service locator, composition-root state, or startup wiring +database/model/migration/repository/Celery/API/authorization changes +agent adapter, prompt, provider/model call, checker dispatch, or execution +post-submit compiler behavior changes, including default-checker handling +effective-plan reconstruction or independent canonical/hash algorithms +open Any/dict model-visible configuration or executable suggestion fields +``` + +Default-checker repetition is rejected only by the new unified proposal +validator. This chunk does not change the historical post-submit compiler. + +## Frozen contracts and bounds + +- New Pydantic contracts use `extra="forbid"` and strict scalar validation. +- At most: 100 findings, 200 atomic requirements, 100 pre bindings, 100 post + bindings, 50 capability suggestions, 20 setup notes, and 20 evidence refs + per item. Safe operator text is at most 1,000 characters per field and + rejects control characters, URL/scheme/path/credential/command/import/ + dependency-shaped content where the field is model-produced. +- `GuideEvidenceRef` contains only server-minted immutable lineage: + `source_item_id`, `extraction_usage_id`, canonical output SHA-256, and bounded + numeric start/end ordinals. It contains no raw excerpt, URL, path, + credential, signed reference, caller text, or provider/scratch handle. +- Representative task context is optional and contains only bounded + server-redacted policy-shape identifiers. It contains no actor/user ID, + email, raw task body, submission/review/payment data, secret, URL, or path. +- The ART guide material must be marked verified, contain no legacy raw + representative-task items, and expose only the canonical `content_markdown` + guide field plus ART-verified source items. The unified context receives a + deeply immutable canonical payload snapshot, payload hash, and immutable + source lineage so post-validation mutation cannot change evidence truth. + Extracted guide content remains explicitly untrusted model input; it is not + accepted as output evidence. +- Capability configuration is a tuple of closed key/value parameters. Keys + must be present in the selected canonical definition's policy fields; values + are bounded JSON scalars or bounded scalar tuples, never nested objects, + source code, commands, imports, dependencies, URLs, or paths. + +## Canonical pre-submit projection + +- The projection consumes one exact startup-composed + `PreSubmissionCheckerCatalogue`; it never calls a second builder in product + execution. +- It preserves the complete immutable envelope, `manifest_sha256`, and all 26 + exact definition projections in canonical order, including disabled advisory + rows and all 19 existing definition fields. +- The first 14 `platform_capability` definitions are non-selectable platform + coverage. Only enabled `project_policy`/`policy_primitive` definitions are + selectable project capabilities. +- Disabled mandatory catalogue state makes the projection unavailable; + disabled advisory rows remain visible but non-selectable. +- The projection does not invent timeout, safety, implementation-version, or + other metadata absent from ART-04B1. + +## Canonical post-submit projection + +- The projection remains in `post_submit_policy.py`, adjacent to the existing + compiler-version default snapshot, and consumes + `default_checker_registry().names()` plus + `POST_SUBMIT_DEFAULT_CHECKERS_BY_COMPILER_VERSION`. +- Envelope identity is + `workstream.post_submission_checkers`, schema + `post_submission_checker_capability_projection.v1`, and source version + `POST_SUBMIT_COMPILER_VERSION`; its canonical SHA-256 commits the sorted + registered names, frozen default names, stage, and selectability. +- Capability identity is the registered checker name; capability version is + the frozen source/compiler version; stage is exactly `post_submit`. +- The eight frozen v0.1 defaults are non-selectable platform coverage. The + registered-minus-default set is project-selectable; on current main it is + exactly `check_acceptance_criteria_present`. +- Default/unknown/wrong-stage/stale-snapshot bindings fail in unified proposal + validation. No registry or compiler mutation occurs. + +## Acceptance criteria + +- Strict input/result/evidence schemas reject extra, unsafe, executable, + over-limit, PII-bearing, raw-source, and nested-open configuration fields. +- One atomic requirement has exactly one canonical disposition. Binding and + evidence references resolve to existing requirement/source lineage. +- `platform_covered` requires an exact stage/ID/version reference to an + enabled mandatory pre-submit platform capability or a canonical post-submit + default. Advisory pre-submit rows remain visible but cannot satisfy required + coverage. Ready/blocked status must agree with warning, blocking-gap, and + capability-gap evidence. +- ART projection equality covers the exact 26-entry manifest and hash; no field + is dropped, retyped, inferred, or reordered. +- Platform/default and disabled definitions cannot be selected. Enabled + project capabilities can be selected only at their exact stage/version and + with catalogue-owned policy fields. +- Post-submit projection is derived solely from the canonical registry and + frozen default snapshot; parity drift fails closed. +- Optional representative task context may be omitted without invalidating the + compilation context. +- Static proof finds no new registry, model/provider call, persistence, route, + Celery, authorization, or catalogue mutation. + +## Verification commands + +```bash +(cd backend && .venv/bin/python -m ruff check app/interfaces/project_agents.py app/modules/checkers/catalogue.py app/modules/projects/post_submit_policy.py tests/test_project_guide_compilation_contracts.py) +(cd backend && .venv/bin/python -m pytest -q tests/test_project_guide_compilation_contracts.py tests/test_checker_catalogue.py) +# Hosted Backend lane (supplies WORKSTREAM_TEST_DATABASE_URL/Postgres): +(cd backend && .venv/bin/python -m pytest -q tests/test_project_guide_compilation_contracts.py tests/test_checker_catalogue.py tests/test_checkers.py --cov=app.interfaces.project_agents --cov=app.modules.checkers.catalogue --cov=app.modules.projects.post_submit_policy --cov-report=term-missing --cov-fail-under=90) +# Hosted full Backend matrix and repository coverage gate: +(cd backend && .venv/bin/python -m pytest -q) +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_markdown_links.py +! git diff origin/main -- backend/app/interfaces/project_agents.py backend/app/modules/checkers/catalogue.py backend/app/modules/projects/post_submit_policy.py | rg '^\+.*(class .*Registry|\.register\(|responses\.create|Runner\.run|Celery|Mapped\[|APIRouter)' +git diff --check +``` + +No configured repository type-check command exists on current main; strict +Pydantic construction, Ruff, focused tests, and the full hosted Backend matrix +are the type/runtime gates for this chunk. + +## Required reviewers + +- architecture +- security/auth and data safety +- QA/test +- product/operations +- senior engineering +- reuse/dedup +- test delta +- CI integrity +- docs + +## Human review focus + +Confirm exact phase-owner reuse, no second registry, no executable or leaking +model fields, and no change to catalogue/compiler/runtime behavior. + +## Stop conditions + +Stop if current canonical sources cannot provide a deterministic read-only +projection without inventing authority metadata, if a projection requires +registry/compiler mutation, or if safe strict configuration requires an open +model-visible object. diff --git a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-01-internal-review-evidence.md b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-01-internal-review-evidence.md new file mode 100644 index 000000000..c4203c3e5 --- /dev/null +++ b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-01-internal-review-evidence.md @@ -0,0 +1,41 @@ +# WS-POL-003-01 Internal Review Evidence + +Date: 2026-08-08. Risk: L1. + +## Deterministic evidence + +- Scoped Ruff: passed. +- Focused non-database tests: 66 passed. +- Changed-subsystem coverage with the neighboring checker tests reached above + 90 percent; database-backed completion remains assigned to the hosted + Backend lane because it supplies Postgres and `WORKSTREAM_TEST_DATABASE_URL`. +- Stale Workstream wording, stale authorization docs, Markdown links, static + boundary scan, and `git diff --check`: passed. +- No workflow, coverage threshold, skip, xfail, or bypass change exists. + +## Review results + +- Architecture: pass after replacing mutable legacy material with an immutable + canonical payload/hash/lineage snapshot and excluding advisory/disabled + capabilities from required platform coverage. +- Security: pass after closing evidence lineage, non-finite scalar, unsafe + text/path/PII, service-owned version, status, platform-coverage, and mutable + context gaps. +- Product/operations: pass after ready/blocked status consistency and exact + platform-coverage proof were enforced. +- QA: pass; strict scalar, catalogue parity, immutable projection, status, + binding, evidence, and stage cases are covered. +- Senior engineering: pass after deep immutability and canonical snapshot hash + validation were added. +- Reuse/dedup: pass; the pre-submit projection consumes + `manifest_entry()` and the post-submit projection consumes the existing + registry/default snapshot without another registry. +- Test delta: pass after invalid parameter ownership and all platform-coverage + branches received regression tests. +- CI integrity: pass; local non-database and hosted database/full-suite duties + are explicit and no gate is weakened. +- Docs: pass; the active plan now distinguishes POL-01 context fields from + later correction/persistence fields. + +All blocking findings were corrected and re-reviewed. No reviewer session +remains open. diff --git a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-01-pr-trust-bundle.md b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-01-pr-trust-bundle.md new file mode 100644 index 000000000..b6e57db13 --- /dev/null +++ b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-01-pr-trust-bundle.md @@ -0,0 +1,53 @@ +# WS-POL-003-01 PR Trust Bundle + +## Intent + +Create the strict unified project-guide compilation contract and read-only +pre/post capability projections without activating a model call, persistence, +API, worker, authorization action, or checker execution path. + +## Design and scope + +- Snapshot exact ART-verified guide material as immutable canonical bytes, + payload hash, and immutable source lineage. +- Treat model output as an untrusted bounded proposal with closed findings, + atomic requirements, artifact policy, capability bindings, evidence refs, + suggestions, and setup notes. +- Project all 26 ART pre-submit definitions from the supplied canonical + catalogue and preserve its manifest/hash/state. +- Project post-submit capabilities from the existing checker registry and + frozen compiler-version defaults. +- Validate exact stage/version/selectability, catalogue-owned parameters, + status consistency, evidence lineage, and mandatory/default platform + coverage fail closed. + +## Explicitly unchanged + +No registry/default mutation, compiler behavior change, model/provider call, +database model or migration, route, Celery task, authorization action, +composition wiring, or live lifecycle change. + +## Evidence + +- Ruff passed. +- Focused tests: 66 passed. +- Changed subsystem coverage remains above 90 percent in the combined focused + run; hosted Backend lanes own the Postgres-backed completion and repository + coverage floor. +- Stale wording, stale authorization docs, Markdown links, static boundary + scan, and whitespace validation passed. +- Architecture, security, product/operations, QA, senior engineering, + reuse/dedup, test-delta, CI-integrity, and docs reviewers passed after all + valid findings were fixed. + +## Human review focus + +Confirm that the snapshot is deeply immutable, phase-owner catalogue truth is +reused without duplication, advisory/default rows cannot masquerade as +required project coverage, and this chunk does not activate runtime behavior. + +## Remaining external proof + +GitHub must run the complete Backend matrix, database-backed focused coverage, +repository coverage floor, and external CodeRabbit review on the exact PR +head before merge. diff --git a/backend/app/interfaces/project_agents.py b/backend/app/interfaces/project_agents.py index 9eb3e0a78..31ad2ead6 100644 --- a/backend/app/interfaces/project_agents.py +++ b/backend/app/interfaces/project_agents.py @@ -2,12 +2,628 @@ from __future__ import annotations +import hashlib import json -from typing import Any, Literal, Protocol - -from pydantic import BaseModel, ConfigDict, Field +import math +import re +from enum import StrEnum +from typing import Annotated, Any, Literal, Protocol +from uuid import UUID + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, + field_validator, + model_validator, +) MAXIMUM_VERIFIED_GUIDE_AGENT_MATERIAL_BYTES = 12 * 1024 * 1024 +MAXIMUM_COMPILATION_FINDINGS = 100 +MAXIMUM_COMPILATION_REQUIREMENTS = 200 +MAXIMUM_COMPILATION_BINDINGS = 100 +MAXIMUM_COMPILATION_SUGGESTIONS = 50 +MAXIMUM_COMPILATION_NOTES = 20 +MAXIMUM_EVIDENCE_REFS = 20 + +_SAFE_IDENTIFIER = re.compile(r"^[a-z][a-z0-9_.-]{0,99}$") +_UNSAFE_MODEL_TEXT = re.compile( + r"[\x00-\x08\x0b\x0c\x0e-\x1f]|(?:https?|file|data|ssh)://|" + r"(?:^|\s)(?:/|\\\\|\.\.?/|[a-z]:\\|[\w.-]+/[\w./-]+)|" + r"\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b|" + r"\b(?:password|secret|credential|bearer|" + r"api[_ -]?key|token)\b\s*[:=]|\brequire\s*\(|\b(?:import|pip install|npm install|" + r"curl|wget|powershell|bash|sh)\b", + re.IGNORECASE, +) + + +def _validated_safe_model_text(value: str) -> str: + if not value or len(value) > 1000 or _UNSAFE_MODEL_TEXT.search(value): + raise ValueError("model-produced text is unsafe") + return value + + +def _validated_identifier(value: str) -> str: + if not _SAFE_IDENTIFIER.fullmatch(value): + raise ValueError("identifier is invalid") + return value + + +class CompilationStage(StrEnum): + PRE_SUBMIT = "pre_submit" + POST_SUBMIT = "post_submit" + + +class RequirementDisposition(StrEnum): + PLATFORM_COVERED = "platform_covered" + SUPPORTED_PRE_SUBMIT = "supported_pre_submit" + PRE_SUBMIT_CAPABILITY_GAP = "pre_submit_capability_gap" + SUPPORTED_POST_SUBMIT = "supported_post_submit" + POST_SUBMIT_CAPABILITY_GAP = "post_submit_capability_gap" + HUMAN_REVIEW = "human_review" + PROJECT_LIFECYCLE_POLICY = "project_lifecycle_policy" + GUIDE_BLOCKER = "guide_blocker" + INFORMATIONAL = "informational" + + +class ResourceBudget(BaseModel): + """Frozen canonical ART resource budget.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + maximum_results: StrictInt = Field(ge=1) + + +class PreSubmissionCapabilityDefinition(BaseModel): + """Exact read-only projection of one ART catalogue definition.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + stable_id: str + version: str + public_name: str + owner: str + phase: str + order: int = Field(ge=0) + dependencies: tuple[str, ...] + classification: str + typed_inputs: tuple[str, ...] + result_schema: str + failure_code: str + resource_budget: ResourceBudget + state: Literal["enabled", "disabled"] + disabled_behavior: str + policy_trace_source: str + dispatch_kind: Literal["platform_capability", "policy_primitive"] + dispatch_capability: str + primitive: str | None = None + policy_fields: tuple[str, ...] = () + selectable: bool + + +class PreSubmissionCapabilityProjection(BaseModel): + """Complete immutable ART catalogue plus model-facing selectability.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + catalogue_id: Literal["workstream.pre_submission_checkers"] + version: Literal["v0.1"] + schema_version: Literal["pre_submission_checker_catalogue.v1"] + manifest_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + available: bool + definitions: tuple[PreSubmissionCapabilityDefinition, ...] + + +class PostSubmissionCapabilityDefinition(BaseModel): + """One registered post-submit capability in a frozen source snapshot.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + capability_id: str + capability_version: str + stage: Literal["post_submit"] = "post_submit" + platform_default: bool + selectable: bool + + +class PostSubmissionCapabilityProjection(BaseModel): + """Read-only projection of CHECKER registration and frozen defaults.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + catalogue_id: Literal["workstream.post_submission_checkers"] + source_version: str + schema_version: Literal["post_submission_checker_capability_projection.v1"] + manifest_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + definitions: tuple[PostSubmissionCapabilityDefinition, ...] + + +class RepresentativeTaskPolicyContext(BaseModel): + """Bounded server-redacted task shape; never task or actor content.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + task_kind: str = Field(max_length=100) + deliverable_kinds: tuple[str, ...] = Field(default=(), max_length=20) + required_evidence_kinds: tuple[str, ...] = Field(default=(), max_length=20) + + @field_validator("task_kind") + @classmethod + def validate_task_kind(cls, value: str) -> str: + return _validated_identifier(value) + + @field_validator("deliverable_kinds", "required_evidence_kinds") + @classmethod + def validate_task_identifiers(cls, values: tuple[str, ...]) -> tuple[str, ...]: + if len(values) != len(set(values)): + raise ValueError("task policy identifiers must be unique") + return tuple(_validated_identifier(value) for value in values) + + +class GuideEvidenceRef(BaseModel): + """Server-minted reference to immutable extracted guide content.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + source_item_id: UUID + extraction_usage_id: UUID + canonical_output_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + start_ordinal: StrictInt = Field(ge=0, le=10_000_000) + end_ordinal: StrictInt = Field(gt=0, le=10_000_000) + + @model_validator(mode="after") + def validate_ordinals(self) -> GuideEvidenceRef: + if self.end_ordinal <= self.start_ordinal: + raise ValueError("evidence ordinals are invalid") + return self + + +class GuideSourceLineageRef(BaseModel): + """Immutable source lineage available for evidence resolution.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + source_item_id: UUID + extraction_usage_id: UUID + canonical_output_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class VerifiedGuideMaterialSnapshot(BaseModel): + """Deeply immutable canonical snapshot of exact ART-verified material.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + project_id: str + guide_id: str + guide_version: str + source_snapshot_id: str + source_snapshot_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + canonical_payload: bytes = Field(max_length=MAXIMUM_VERIFIED_GUIDE_AGENT_MATERIAL_BYTES) + canonical_payload_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + source_lineage: tuple[GuideSourceLineageRef, ...] + + @model_validator(mode="after") + def validate_snapshot_integrity(self) -> VerifiedGuideMaterialSnapshot: + expected_hash = "sha256:" + hashlib.sha256(self.canonical_payload).hexdigest() + if self.canonical_payload_sha256 != expected_hash: + raise ValueError("canonical guide material hash is invalid") + identities = { + (item.source_item_id, item.extraction_usage_id) for item in self.source_lineage + } + if len(identities) != len(self.source_lineage): + raise ValueError("canonical guide source lineage contains duplicates") + return self + + @classmethod + def from_material(cls, material: GuideSourceMaterial) -> VerifiedGuideMaterialSnapshot: + if not material.verified_artifact_material: + raise ValueError("compilation requires ART-verified guide material") + if material.representative_task_material.items: + raise ValueError("raw representative-task material is forbidden") + if set(material.guide_material) != {"content_markdown"}: + raise ValueError("guide material contains non-canonical fields") + if not isinstance(material.guide_material["content_markdown"], str): + raise ValueError("canonical guide content must be text") + payload = canonical_guide_source_material_bytes(material) + if len(payload) > MAXIMUM_VERIFIED_GUIDE_AGENT_MATERIAL_BYTES: + raise ValueError("canonical guide material exceeds the bounded input") + lineage = tuple( + GuideSourceLineageRef( + source_item_id=UUID(item.source_item_id), + extraction_usage_id=UUID(item.extraction_usage_id), + canonical_output_sha256=item.canonical_output_sha256, + ) + for item in material.source_items + if item.source_item_id and item.extraction_usage_id and item.canonical_output_sha256 + ) + return cls( + project_id=material.project_id, + guide_id=material.guide_id, + guide_version=material.guide_version, + source_snapshot_id=material.source_snapshot_id, + source_snapshot_hash=material.source_snapshot_hash, + canonical_payload=payload, + canonical_payload_sha256="sha256:" + hashlib.sha256(payload).hexdigest(), + source_lineage=lineage, + ) + + +CapabilityScalar = Annotated[ + StrictStr | StrictInt | StrictFloat | StrictBool, + Field(union_mode="left_to_right"), +] + + +class CapabilityParameter(BaseModel): + """Closed flat capability configuration supplied for trusted validation.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + name: str + value: CapabilityScalar | tuple[CapabilityScalar, ...] + + @field_validator("name") + @classmethod + def validate_name(cls, value: str) -> str: + return _validated_identifier(value) + + @field_validator("value") + @classmethod + def validate_value( + cls, value: CapabilityScalar | tuple[CapabilityScalar, ...] + ) -> CapabilityScalar | tuple[CapabilityScalar, ...]: + values = value if isinstance(value, tuple) else (value,) + if not values or len(values) > 50: + raise ValueError("capability parameter value is invalid") + for item in values: + if type(item) not in {str, int, float, bool}: + raise ValueError("capability parameter scalar is invalid") + if isinstance(item, str): + _validated_safe_model_text(item) + elif isinstance(item, (int, float)): + if not math.isfinite(item) or abs(item) > 10**12: + raise ValueError("capability parameter number is out of range") + return value + + +class CapabilityBindingProposal(BaseModel): + """One stage-bound proposal against canonical capability truth.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + requirement_id: str + capability_id: str + capability_version: str + stage: CompilationStage + parameters: tuple[CapabilityParameter, ...] = Field(default=(), max_length=50) + + @field_validator("requirement_id", "capability_id", "capability_version") + @classmethod + def validate_identifiers(cls, value: str) -> str: + return _validated_identifier(value) + + @model_validator(mode="after") + def validate_parameter_names(self) -> CapabilityBindingProposal: + names = [parameter.name for parameter in self.parameters] + if len(names) != len(set(names)): + raise ValueError("capability parameters must be unique") + return self + + +class CompilationFinding(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + severity: Literal["blocking_gap", "warning", "info"] + code: str + message: str + evidence_refs: tuple[GuideEvidenceRef, ...] = Field( + default=(), max_length=MAXIMUM_EVIDENCE_REFS + ) + + _code = field_validator("code")(_validated_identifier) + _message = field_validator("message")(_validated_safe_model_text) + + +class PlatformCoverageRef(BaseModel): + """Exact non-selectable phase-owner capability covering a requirement.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + capability_id: str + capability_version: str + stage: CompilationStage + + _capability_identity = field_validator("capability_id", "capability_version")( + _validated_identifier + ) + + +class AtomicGuideRequirement(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + requirement_id: str + statement: str + disposition: RequirementDisposition + platform_coverage: PlatformCoverageRef | None = None + evidence_refs: tuple[GuideEvidenceRef, ...] = Field( + default=(), max_length=MAXIMUM_EVIDENCE_REFS + ) + + _requirement_id = field_validator("requirement_id")(_validated_identifier) + _statement = field_validator("statement")(_validated_safe_model_text) + + +class SubmissionArtifactPolicyProposal(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + packaging: Literal["zip"] = "zip" + maximum_file_size_bytes: StrictInt = Field(gt=0, le=10 * 1024 * 1024 * 1024) + maximum_package_size_bytes: StrictInt = Field(gt=0, le=10 * 1024 * 1024 * 1024) + allowed_storage_schemes: tuple[Literal["artifact"], ...] = ("artifact",) + required_artifacts: tuple[str, ...] = Field(default=(), max_length=100) + forbidden_artifacts: tuple[str, ...] = Field(default=(), max_length=100) + required_evidence: tuple[str, ...] = Field(default=(), max_length=100) + attestation_terms: tuple[str, ...] = Field(default=(), max_length=50) + + @field_validator( + "required_artifacts", "forbidden_artifacts", "required_evidence", "attestation_terms" + ) + @classmethod + def validate_policy_text(cls, values: tuple[str, ...]) -> tuple[str, ...]: + if len(values) != len(set(values)): + raise ValueError("artifact policy values must be unique") + return tuple(_validated_safe_model_text(value) for value in values) + + @model_validator(mode="after") + def validate_package_limit(self) -> SubmissionArtifactPolicyProposal: + if self.maximum_file_size_bytes > self.maximum_package_size_bytes: + raise ValueError("file limit exceeds package limit") + if set(self.required_artifacts).intersection(self.forbidden_artifacts): + raise ValueError("artifact policy requirements conflict") + return self + + +class CapabilitySuggestion(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + title: str + rationale: str + evidence_refs: tuple[GuideEvidenceRef, ...] = Field( + default=(), max_length=MAXIMUM_EVIDENCE_REFS + ) + + _title = field_validator("title")(_validated_safe_model_text) + _rationale = field_validator("rationale")(_validated_safe_model_text) + + +class ProjectGuideCompilationContext(BaseModel): + """Exact bounded input for one future unified compilation attempt.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + material: VerifiedGuideMaterialSnapshot + setup_run_id: UUID + setup_generation: StrictInt = Field(ge=1) + instruction_version: str = Field(max_length=100) + agent_identity: str = Field(max_length=100) + pre_submission_capabilities: PreSubmissionCapabilityProjection + post_submission_capabilities: PostSubmissionCapabilityProjection + representative_task: RepresentativeTaskPolicyContext | None = None + + +class ProjectGuideCompilationResult(BaseModel): + """Strict untrusted proposal; trusted code must validate it with context.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + status: Literal["guide_blocked", "draft_ready", "draft_ready_with_warnings"] + findings: tuple[CompilationFinding, ...] = Field( + default=(), max_length=MAXIMUM_COMPILATION_FINDINGS + ) + submission_artifact_policy: SubmissionArtifactPolicyProposal | None = None + requirements: tuple[AtomicGuideRequirement, ...] = Field( + default=(), max_length=MAXIMUM_COMPILATION_REQUIREMENTS + ) + pre_submit_bindings: tuple[CapabilityBindingProposal, ...] = Field( + default=(), max_length=MAXIMUM_COMPILATION_BINDINGS + ) + post_submit_bindings: tuple[CapabilityBindingProposal, ...] = Field( + default=(), max_length=MAXIMUM_COMPILATION_BINDINGS + ) + capability_suggestions: tuple[CapabilitySuggestion, ...] = Field( + default=(), max_length=MAXIMUM_COMPILATION_SUGGESTIONS + ) + setup_notes: tuple[str, ...] = Field(default=(), max_length=MAXIMUM_COMPILATION_NOTES) + agent_name: Literal["ProjectGuideCompilationAgent"] = "ProjectGuideCompilationAgent" + agent_version: str = Field(max_length=100) + schema_version: Literal["project_guide_compilation_result.v1"] = ( + "project_guide_compilation_result.v1" + ) + + @field_validator("setup_notes") + @classmethod + def validate_notes(cls, values: tuple[str, ...]) -> tuple[str, ...]: + return tuple(_validated_safe_model_text(value) for value in values) + + _agent_version = field_validator("agent_version")(_validated_identifier) + + +def validate_project_guide_compilation_result( + context: ProjectGuideCompilationContext, + result: ProjectGuideCompilationResult, +) -> None: + """Fail closed when an untrusted result diverges from canonical capability truth.""" + requirements = {item.requirement_id: item for item in result.requirements} + if len(requirements) != len(result.requirements): + raise ValueError("compilation requirements must be unique") + _validate_status_consistency(result) + if result.status == "guide_blocked": + if ( + result.submission_artifact_policy + or result.pre_submit_bindings + or result.post_submit_bindings + ): + raise ValueError("blocked guide cannot publish policy proposals") + elif result.submission_artifact_policy is None: + raise ValueError("draft-ready compilation requires artifact policy") + + pre_definitions = { + definition.stable_id: definition + for definition in context.pre_submission_capabilities.definitions + } + post_definitions = { + definition.capability_id: definition + for definition in context.post_submission_capabilities.definitions + } + _validate_platform_coverage(result.requirements, pre_definitions, post_definitions) + _validate_evidence_lineage(context, result) + pre_bound_requirements = _validate_bindings( + result.pre_submit_bindings, requirements, pre_definitions, "pre_submit" + ) + post_bound_requirements = _validate_bindings( + result.post_submit_bindings, requirements, post_definitions, "post_submit" + ) + expected_pre = { + item.requirement_id + for item in result.requirements + if item.disposition is RequirementDisposition.SUPPORTED_PRE_SUBMIT + } + expected_post = { + item.requirement_id + for item in result.requirements + if item.disposition is RequirementDisposition.SUPPORTED_POST_SUBMIT + } + if pre_bound_requirements != expected_pre or post_bound_requirements != expected_post: + raise ValueError("supported compilation requirements must have one binding") + + +def _validate_status_consistency(result: ProjectGuideCompilationResult) -> None: + has_blocker = any(finding.severity == "blocking_gap" for finding in result.findings) or any( + requirement.disposition + in { + RequirementDisposition.GUIDE_BLOCKER, + RequirementDisposition.PRE_SUBMIT_CAPABILITY_GAP, + RequirementDisposition.POST_SUBMIT_CAPABILITY_GAP, + } + for requirement in result.requirements + ) + has_warning = any(finding.severity == "warning" for finding in result.findings) + if result.status == "guide_blocked" and not has_blocker: + raise ValueError("blocked compilation requires blocking evidence") + if result.status != "guide_blocked" and has_blocker: + raise ValueError("ready compilation cannot contain blocking evidence") + if result.status == "draft_ready" and has_warning: + raise ValueError("draft-ready compilation cannot contain warnings") + if result.status == "draft_ready_with_warnings" and not has_warning: + raise ValueError("warning-ready compilation requires a warning") + + +def _validate_platform_coverage( + requirements: tuple[AtomicGuideRequirement, ...], + pre_definitions: dict[str, PreSubmissionCapabilityDefinition], + post_definitions: dict[str, PostSubmissionCapabilityDefinition], +) -> None: + for requirement in requirements: + coverage = requirement.platform_coverage + if requirement.disposition is not RequirementDisposition.PLATFORM_COVERED: + if coverage is not None: + raise ValueError("non-platform requirement cannot claim platform coverage") + continue + if coverage is None: + raise ValueError("platform-covered requirement requires canonical proof") + if coverage.stage is CompilationStage.PRE_SUBMIT: + definition = pre_definitions.get(coverage.capability_id) + valid = ( + definition is not None + and definition.version == coverage.capability_version + and definition.dispatch_kind == "platform_capability" + and definition.state == "enabled" + and definition.classification != "advisory" + and not definition.selectable + ) + else: + post_definition = post_definitions.get(coverage.capability_id) + valid = ( + post_definition is not None + and post_definition.capability_version == coverage.capability_version + and post_definition.platform_default + and not post_definition.selectable + ) + if not valid: + raise ValueError("platform coverage does not resolve to canonical truth") + + +def _validate_evidence_lineage( + context: ProjectGuideCompilationContext, + result: ProjectGuideCompilationResult, +) -> None: + source_lineage = { + ( + str(item.source_item_id), + str(item.extraction_usage_id), + item.canonical_output_sha256, + ) + for item in context.material.source_lineage + } + evidence_refs = ( + *(ref for finding in result.findings for ref in finding.evidence_refs), + *(ref for requirement in result.requirements for ref in requirement.evidence_refs), + *(ref for suggestion in result.capability_suggestions for ref in suggestion.evidence_refs), + ) + for evidence in evidence_refs: + lineage = ( + str(evidence.source_item_id), + str(evidence.extraction_usage_id), + evidence.canonical_output_sha256, + ) + if lineage not in source_lineage: + raise ValueError("compilation evidence does not resolve to source lineage") + + +def _validate_bindings( + bindings: tuple[CapabilityBindingProposal, ...], + requirements: dict[str, AtomicGuideRequirement], + definitions: dict[str, PreSubmissionCapabilityDefinition | PostSubmissionCapabilityDefinition], + expected_stage: Literal["pre_submit", "post_submit"], +) -> set[str]: + seen_requirements: set[str] = set() + expected_disposition = ( + RequirementDisposition.SUPPORTED_PRE_SUBMIT + if expected_stage == "pre_submit" + else RequirementDisposition.SUPPORTED_POST_SUBMIT + ) + for binding in bindings: + requirement = requirements.get(binding.requirement_id) + definition = definitions.get(binding.capability_id) + if ( + requirement is None + or requirement.disposition is not expected_disposition + or binding.requirement_id in seen_requirements + or definition is None + or not definition.selectable + or binding.stage.value != expected_stage + ): + raise ValueError("compilation capability binding is invalid") + version = ( + definition.version + if isinstance(definition, PreSubmissionCapabilityDefinition) + else definition.capability_version + ) + if binding.capability_version != version: + raise ValueError("compilation capability version is stale") + if isinstance(definition, PreSubmissionCapabilityDefinition): + allowed_fields = set(definition.policy_fields) + if any(parameter.name not in allowed_fields for parameter in binding.parameters): + raise ValueError("pre-submit capability parameters are invalid") + elif binding.parameters: + raise ValueError("post-submit capability parameters are not supported") + seen_requirements.add(binding.requirement_id) + return seen_requirements class ProjectAgentRuntimeError(Exception): diff --git a/backend/app/modules/checkers/catalogue.py b/backend/app/modules/checkers/catalogue.py index e3426ff43..0a92fea97 100644 --- a/backend/app/modules/checkers/catalogue.py +++ b/backend/app/modules/checkers/catalogue.py @@ -8,6 +8,10 @@ from typing import Any, Mapping from app.core.hashing import canonical_json_hash +from app.interfaces.project_agents import ( + PreSubmissionCapabilityDefinition, + PreSubmissionCapabilityProjection, +) PRE_SUBMISSION_CATALOGUE_ID = "workstream.pre_submission_checkers" @@ -44,9 +48,7 @@ class PreSubmissionCheckerPhase(StrEnum): PROJECT_POLICY = "project_policy" -_PHASE_ORDER = { - phase: index for index, phase in enumerate(PreSubmissionCheckerPhase) -} +_PHASE_ORDER = {phase: index for index, phase in enumerate(PreSubmissionCheckerPhase)} def pre_submission_phase_order(phase: PreSubmissionCheckerPhase) -> int: @@ -324,6 +326,31 @@ def parse_disabled_pre_submission_checker_ids(raw: str) -> frozenset[str]: return frozenset(parts) +def project_guide_pre_submission_capabilities( + catalogue: PreSubmissionCheckerCatalogue, +) -> PreSubmissionCapabilityProjection: + """Project the exact deployment catalogue without creating policy authority.""" + definitions = tuple( + PreSubmissionCapabilityDefinition( + **entry.manifest_entry(), + selectable=( + entry.state is PreSubmissionCheckerState.ENABLED + and entry.dispatch_kind is PreSubmissionDispatchKind.POLICY_PRIMITIVE + and entry.phase is PreSubmissionCheckerPhase.PROJECT_POLICY + ), + ) + for entry in catalogue.entries + ) + return PreSubmissionCapabilityProjection( + catalogue_id=catalogue.catalogue_id, + version=catalogue.version, + schema_version=catalogue.schema_version, + manifest_sha256=catalogue.manifest_sha256, + available=catalogue.available, + definitions=definitions, + ) + + def _definition_sort_key(entry: PreSubmissionCheckerDefinition) -> tuple[int, int, str]: return (_PHASE_ORDER[entry.phase], entry.order, entry.stable_id) diff --git a/backend/app/modules/projects/post_submit_policy.py b/backend/app/modules/projects/post_submit_policy.py index fffa007fb..b5b7ed8e0 100644 --- a/backend/app/modules/projects/post_submit_policy.py +++ b/backend/app/modules/projects/post_submit_policy.py @@ -7,6 +7,10 @@ from typing import Any from app.core.hashing import canonical_json_hash +from app.interfaces.project_agents import ( + PostSubmissionCapabilityDefinition, + PostSubmissionCapabilityProjection, +) from app.modules.checkers.runner import ( UnknownChecker, default_checker_registry, @@ -62,12 +66,46 @@ ) PLATFORM_BLOCKING_SEVERITIES = ("critical", "high") PLATFORM_BLOCKING_SEVERITY_SET = frozenset(PLATFORM_BLOCKING_SEVERITIES) +POST_SUBMISSION_CAPABILITY_CATALOGUE_ID = "workstream.post_submission_checkers" +POST_SUBMISSION_CAPABILITY_SCHEMA_VERSION = "post_submission_checker_capability_projection.v1" class PostSubmitCheckerCompilerError(ValueError): """Raised when post-submit checker policy compilation fails closed.""" +def project_guide_post_submission_capabilities( + *, compiler_version: str = POST_SUBMIT_COMPILER_VERSION +) -> PostSubmissionCapabilityProjection: + """Project registered CHECKER truth and frozen defaults without a new registry.""" + defaults = _default_checkers_for_compiler_version(compiler_version) + registered = tuple(sorted(default_checker_registry().names())) + default_set = frozenset(defaults) + if not default_set.issubset(registered): + raise PostSubmitCheckerCompilerError( + "post-submit default checker registration parity is invalid" + ) + definitions = tuple( + PostSubmissionCapabilityDefinition( + capability_id=name, + capability_version=compiler_version, + platform_default=name in default_set, + selectable=name not in default_set, + ) + for name in registered + ) + body = { + "catalogue_id": POST_SUBMISSION_CAPABILITY_CATALOGUE_ID, + "source_version": compiler_version, + "schema_version": POST_SUBMISSION_CAPABILITY_SCHEMA_VERSION, + "definitions": [definition.model_dump(mode="json") for definition in definitions], + } + return PostSubmissionCapabilityProjection( + **body, + manifest_sha256=canonical_json_hash(body), + ) + + @dataclass(frozen=True) class LockedPostSubmitCheckerPolicy: """Validated locked post-submit checker policy body.""" @@ -324,7 +362,9 @@ def _validate_spec_shape( raise PostSubmitCheckerCompilerError("post-submit checker spec guide context mismatch") for field_name in ("required_checkers", "warning_checkers", "blocking_severities"): if not _string_list(spec.get(field_name)): - raise PostSubmitCheckerCompilerError(f"post-submit checker spec {field_name} is invalid") + raise PostSubmitCheckerCompilerError( + f"post-submit checker spec {field_name} is invalid" + ) def _validate_checker_classifications( diff --git a/backend/tests/test_project_guide_compilation_contracts.py b/backend/tests/test_project_guide_compilation_contracts.py new file mode 100644 index 000000000..b3b42fa13 --- /dev/null +++ b/backend/tests/test_project_guide_compilation_contracts.py @@ -0,0 +1,641 @@ +from __future__ import annotations + +from uuid import UUID, uuid4 + +import pytest +from pydantic import ValidationError + +from app.interfaces.project_agents import ( + AtomicGuideRequirement, + CapabilityBindingProposal, + CapabilityParameter, + CapabilitySuggestion, + CompilationFinding, + GuideEvidenceRef, + GuideSourceMaterial, + PlatformCoverageRef, + ProjectGuideCompilationContext, + ProjectGuideCompilationResult, + RepresentativeTaskPolicyContext, + SubmissionArtifactPolicyProposal, + VerifiedGuideMaterialSnapshot, + validate_project_guide_compilation_result, +) +from app.modules.checkers.catalogue import ( + PreSubmissionCheckerClassification, + build_pre_submission_checker_catalogue, + project_guide_pre_submission_capabilities, +) +from app.modules.projects.post_submit_policy import ( + POST_SUBMIT_COMPILER_VERSION, + POST_SUBMIT_V01_DEFAULT_CHECKERS, + PostSubmitCheckerCompilerError, + project_guide_post_submission_capabilities, +) + + +SHA256 = "sha256:" + "a" * 64 +SOURCE_ITEM_ID = UUID("11111111-1111-1111-1111-111111111111") +EXTRACTION_USAGE_ID = UUID("22222222-2222-2222-2222-222222222222") + + +def _context() -> ProjectGuideCompilationContext: + material = GuideSourceMaterial( + project_id=str(uuid4()), + guide_id=str(uuid4()), + guide_version="v1", + source_snapshot_id=str(uuid4()), + 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(SOURCE_ITEM_ID), + "extraction_usage_id": str(EXTRACTION_USAGE_ID), + "canonical_output_sha256": SHA256, + } + ], + ) + return ProjectGuideCompilationContext( + material=VerifiedGuideMaterialSnapshot.from_material(material), + setup_run_id=uuid4(), + setup_generation=1, + 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 _evidence() -> GuideEvidenceRef: + return GuideEvidenceRef( + source_item_id=SOURCE_ITEM_ID, + extraction_usage_id=EXTRACTION_USAGE_ID, + canonical_output_sha256=SHA256, + start_ordinal=0, + end_ordinal=10, + ) + + +def _artifact_policy() -> SubmissionArtifactPolicyProposal: + return SubmissionArtifactPolicyProposal( + maximum_file_size_bytes=1_000, + maximum_package_size_bytes=10_000, + required_artifacts=("submission",), + ) + + +def test_pre_submission_projection_preserves_exact_manifest_and_selectability() -> None: + catalogue = build_pre_submission_checker_catalogue( + disabled_entry_ids=frozenset({"artifact.quality.placeholder_signal"}) + ) + projection = project_guide_pre_submission_capabilities(catalogue) + + assert projection.manifest_sha256 == catalogue.manifest_sha256 + assert len(projection.definitions) == 26 + assert [item.stable_id for item in projection.definitions] == [ + item.stable_id for item in catalogue.entries + ] + for definition, source in zip(projection.definitions, catalogue.entries, strict=True): + projected = definition.model_dump(mode="json") + selectable = projected.pop("selectable") + assert projected == source.manifest_entry() + assert selectable is ( + source.state.value == "enabled" + and source.dispatch_kind.value == "policy_primitive" + and source.phase.value == "project_policy" + ) + disabled = next( + item + for item in projection.definitions + if item.stable_id == "artifact.quality.placeholder_signal" + ) + assert disabled.state == "disabled" + assert disabled.selectable is False + assert projection.available is True + + +def test_pre_submission_projection_reports_disabled_mandatory_unavailable() -> None: + catalogue = build_pre_submission_checker_catalogue( + disabled_entry_ids=frozenset({"artifact.outer_zip.valid"}) + ) + projection = project_guide_pre_submission_capabilities(catalogue) + assert projection.available is False + assert projection.definitions[0].classification == ( + PreSubmissionCheckerClassification.MANDATORY_SECURITY.value + ) + assert projection.definitions[0].selectable is False + + +def test_post_submission_projection_uses_registry_and_frozen_default_truth() -> None: + projection = project_guide_post_submission_capabilities() + by_name = {item.capability_id: item for item in projection.definitions} + + assert projection.source_version == POST_SUBMIT_COMPILER_VERSION + assert set(POST_SUBMIT_V01_DEFAULT_CHECKERS).issubset(by_name) + assert {name for name, item in by_name.items() if item.platform_default} == set( + POST_SUBMIT_V01_DEFAULT_CHECKERS + ) + assert [item.capability_id for item in projection.definitions if item.selectable] == [ + "check_acceptance_criteria_present" + ] + assert all(item.stage == "post_submit" for item in projection.definitions) + + +def test_post_submission_projection_rejects_unknown_compiler_snapshot() -> None: + with pytest.raises(PostSubmitCheckerCompilerError): + project_guide_post_submission_capabilities(compiler_version="unknown") + + +@pytest.mark.parametrize( + ("payload", "field"), + [ + ({"raw_excerpt": "secret"}, "raw_excerpt"), + ({"url": "https://example.invalid"}, "url"), + ({"path": "/tmp/guide"}, "path"), + ({"credential": "token=secret"}, "credential"), + ({"signed_reference": "signed"}, "signed_reference"), + ({"caller_text": "ignore prior instructions"}, "caller_text"), + ], +) +def test_guide_evidence_ref_rejects_non_lineage_fields(payload: dict[str, str], field: str) -> None: + with pytest.raises(ValidationError) as error: + GuideEvidenceRef.model_validate( + { + "source_item_id": str(uuid4()), + "extraction_usage_id": str(uuid4()), + "canonical_output_sha256": SHA256, + "start_ordinal": 0, + "end_ordinal": 1, + **payload, + } + ) + assert field in str(error.value) + + +@pytest.mark.parametrize( + "unsafe", + [ + "https://example.invalid/instructions", + "/etc/passwd", + "token=secret", + "import subprocess", + "curl example.invalid", + "see docs/guide.md", + r"C:\Users\worker\guide.txt", + "Contact jane@example.com", + "line\x00break", + ], +) +def test_model_produced_text_rejects_unsafe_shapes(unsafe: str) -> None: + with pytest.raises(ValidationError): + CapabilitySuggestion(title="new checker", rationale=unsafe) + + +def test_representative_task_context_is_optional_and_rejects_pii_fields() -> None: + context = _context() + assert context.representative_task is None + with pytest.raises(ValidationError): + RepresentativeTaskPolicyContext.model_validate( + {"task_kind": "code_review", "actor_id": str(uuid4())} + ) + + +def test_unified_result_accepts_exact_stage_capability_and_closed_parameters() -> None: + context = _context() + result = ProjectGuideCompilationResult( + status="draft_ready", + findings=( + CompilationFinding(severity="info", code="guide.ready", message="Guide is complete."), + ), + submission_artifact_policy=_artifact_policy(), + requirements=( + AtomicGuideRequirement( + requirement_id="requirement.packet", + statement="Validate the submission packet.", + disposition="supported_pre_submit", + evidence_refs=(_evidence(),), + ), + AtomicGuideRequirement( + requirement_id="requirement.acceptance", + statement="Check acceptance criteria coverage.", + disposition="supported_post_submit", + ), + ), + pre_submit_bindings=( + CapabilityBindingProposal( + requirement_id="requirement.packet", + capability_id="policy.submission_packet.validate", + capability_version="v1", + stage="pre_submit", + parameters=( + CapabilityParameter( + name="required_packet_fields", value=("summary", "evidence") + ), + ), + ), + ), + post_submit_bindings=( + CapabilityBindingProposal( + requirement_id="requirement.acceptance", + capability_id="check_acceptance_criteria_present", + capability_version=POST_SUBMIT_COMPILER_VERSION, + stage="post_submit", + ), + ), + agent_version="v1", + ) + validate_project_guide_compilation_result(context, result) + + +@pytest.mark.parametrize( + ("capability_id", "version", "stage"), + [ + ("submission.packet.required_fields", "v1", "pre_submit"), + ("policy.submission_packet.validate", "stale", "pre_submit"), + ("unknown.capability", "v1", "pre_submit"), + ("check_submission_packet", POST_SUBMIT_COMPILER_VERSION, "post_submit"), + ("check_acceptance_criteria_present", POST_SUBMIT_COMPILER_VERSION, "pre_submit"), + ], +) +def test_unified_result_rejects_default_unknown_stale_and_wrong_stage_bindings( + capability_id: str, version: str, stage: str +) -> None: + context = _context() + disposition = "supported_post_submit" if stage == "post_submit" else "supported_pre_submit" + binding = CapabilityBindingProposal( + requirement_id="requirement.one", + capability_id=capability_id, + capability_version=version, + stage=stage, + ) + result = ProjectGuideCompilationResult( + status="draft_ready", + submission_artifact_policy=_artifact_policy(), + requirements=( + AtomicGuideRequirement( + requirement_id="requirement.one", + statement="Validate one requirement.", + disposition=disposition, + ), + ), + pre_submit_bindings=(binding,) if stage == "pre_submit" else (), + post_submit_bindings=(binding,) if stage == "post_submit" else (), + agent_version="v1", + ) + with pytest.raises(ValueError, match="capability binding|version"): + validate_project_guide_compilation_result(context, result) + + +def test_unified_result_rejects_open_nested_or_executable_configuration() -> None: + with pytest.raises(ValidationError): + CapabilityParameter(name="required_packet_fields", value={"command": "sh"}) + with pytest.raises(ValidationError): + CapabilityParameter(name="required_packet_fields", value="import subprocess") + with pytest.raises(ValidationError): + CapabilityBindingProposal( + requirement_id="requirement.packet", + capability_id="policy.submission_packet.validate", + capability_version="https://unsafe.invalid", + stage="pre_submit", + ) + with pytest.raises(ValidationError): + PlatformCoverageRef( + capability_id="artifact.outer_zip.valid", + capability_version="https://unsafe.invalid", + stage="pre_submit", + ) + with pytest.raises(ValidationError): + CapabilityParameter(name="required_packet_fields", value=float("nan")) + with pytest.raises(ValidationError): + SubmissionArtifactPolicyProposal( + maximum_file_size_bytes="1000", + maximum_package_size_bytes=10_000, + ) + with pytest.raises(ValidationError): + GuideEvidenceRef( + source_item_id=SOURCE_ITEM_ID, + extraction_usage_id=EXTRACTION_USAGE_ID, + canonical_output_sha256=SHA256, + start_ordinal="0", + end_ordinal=1, + ) + + +def test_pre_submission_projection_resource_budget_is_deeply_frozen() -> None: + projection = project_guide_pre_submission_capabilities(build_pre_submission_checker_catalogue()) + with pytest.raises(ValidationError): + projection.definitions[0].resource_budget.maximum_results = 99 + + +def test_context_rejects_unverified_or_unredacted_legacy_material() -> None: + base = { + "project_id": str(uuid4()), + "guide_id": str(uuid4()), + "guide_version": "v1", + "source_snapshot_id": str(uuid4()), + "source_snapshot_hash": SHA256, + "guide_material": {"content_markdown": "Guide."}, + } + with pytest.raises(ValueError, match="ART-verified"): + VerifiedGuideMaterialSnapshot.from_material(GuideSourceMaterial(**base)) + + with pytest.raises(ValueError, match="representative-task"): + VerifiedGuideMaterialSnapshot.from_material( + GuideSourceMaterial( + **base, + verified_artifact_material=True, + representative_task_material={ + "items": [{"source_kind": "task", "ingestion_adapter": "legacy"}] + }, + ) + ) + + with pytest.raises(ValueError, match="must be text"): + VerifiedGuideMaterialSnapshot.from_material( + GuideSourceMaterial( + **{**base, "guide_material": {"content_markdown": {"command": "sh"}}}, + verified_artifact_material=True, + ) + ) + + +def test_compilation_material_snapshot_cannot_drift_after_validation() -> None: + snapshot = _context().material + with pytest.raises(ValidationError): + snapshot.source_lineage = () + with pytest.raises(TypeError): + snapshot.source_lineage[0] = snapshot.source_lineage[0] + with pytest.raises(ValidationError, match="hash is invalid"): + VerifiedGuideMaterialSnapshot.model_validate( + {**snapshot.model_dump(mode="python"), "canonical_payload": b"changed"} + ) + + +def test_unified_result_rejects_unresolved_evidence_lineage() -> None: + context = _context() + result = ProjectGuideCompilationResult( + status="draft_ready", + findings=( + CompilationFinding( + severity="info", + code="guide.ready", + message="Guide is complete.", + evidence_refs=( + GuideEvidenceRef( + source_item_id=uuid4(), + extraction_usage_id=EXTRACTION_USAGE_ID, + canonical_output_sha256=SHA256, + start_ordinal=0, + end_ordinal=1, + ), + ), + ), + ), + submission_artifact_policy=_artifact_policy(), + agent_version="v1", + ) + with pytest.raises(ValueError, match="source lineage"): + validate_project_guide_compilation_result(context, result) + + +def test_supported_requirement_requires_exactly_one_binding() -> None: + context = _context() + result = ProjectGuideCompilationResult( + status="draft_ready", + submission_artifact_policy=_artifact_policy(), + requirements=( + AtomicGuideRequirement( + requirement_id="requirement.packet", + statement="Validate the submission packet.", + disposition="supported_pre_submit", + ), + ), + agent_version="v1", + ) + with pytest.raises(ValueError, match="must have one binding"): + validate_project_guide_compilation_result(context, result) + + +@pytest.mark.parametrize("stage", ["pre_submit", "post_submit"]) +def test_capability_binding_rejects_unowned_parameters(stage: str) -> None: + context = _context() + is_pre = stage == "pre_submit" + binding = CapabilityBindingProposal( + requirement_id="requirement.one", + capability_id=( + "policy.submission_packet.validate" if is_pre else "check_acceptance_criteria_present" + ), + capability_version="v1" if is_pre else POST_SUBMIT_COMPILER_VERSION, + stage=stage, + parameters=(CapabilityParameter(name="unowned_parameter", value=True),), + ) + result = ProjectGuideCompilationResult( + status="draft_ready", + submission_artifact_policy=_artifact_policy(), + requirements=( + AtomicGuideRequirement( + requirement_id="requirement.one", + statement="Validate one requirement.", + disposition=("supported_pre_submit" if is_pre else "supported_post_submit"), + ), + ), + pre_submit_bindings=(binding,) if is_pre else (), + post_submit_bindings=() if is_pre else (binding,), + agent_version="v1", + ) + with pytest.raises(ValueError, match="parameters"): + validate_project_guide_compilation_result(context, result) + + +@pytest.mark.parametrize( + ("status", "finding_severity", "disposition"), + [ + ("draft_ready", "blocking_gap", "informational"), + ("draft_ready", "info", "guide_blocker"), + ("draft_ready", "warning", "informational"), + ("draft_ready_with_warnings", "info", "informational"), + ("guide_blocked", "info", "informational"), + ], +) +def test_result_status_must_match_findings_and_blocking_dispositions( + status: str, finding_severity: str, disposition: str +) -> None: + result = ProjectGuideCompilationResult( + status=status, + findings=( + CompilationFinding( + severity=finding_severity, + code="guide.status", + message="Guide status evidence.", + ), + ), + submission_artifact_policy=(None if status == "guide_blocked" else _artifact_policy()), + requirements=( + AtomicGuideRequirement( + requirement_id="requirement.status", + statement="Check guide status.", + disposition=disposition, + ), + ), + agent_version="v1", + ) + with pytest.raises(ValueError): + validate_project_guide_compilation_result(_context(), result) + + +@pytest.mark.parametrize( + "invalid_capability_id", + ["policy.submission_packet.validate", "artifact.quality.placeholder_signal"], +) +def test_platform_coverage_requires_exact_mandatory_platform_capability( + invalid_capability_id: str, +) -> None: + context = _context() + valid = ProjectGuideCompilationResult( + status="draft_ready", + submission_artifact_policy=_artifact_policy(), + requirements=( + AtomicGuideRequirement( + requirement_id="requirement.zip", + statement="Validate the outer ZIP.", + disposition="platform_covered", + platform_coverage=PlatformCoverageRef( + capability_id="artifact.outer_zip.valid", + capability_version="v1", + stage="pre_submit", + ), + ), + ), + agent_version="v1", + ) + validate_project_guide_compilation_result(context, valid) + + invalid = valid.model_copy( + update={ + "requirements": ( + valid.requirements[0].model_copy( + update={ + "platform_coverage": PlatformCoverageRef( + capability_id=invalid_capability_id, + capability_version="v1", + stage="pre_submit", + ) + } + ), + ) + } + ) + with pytest.raises(ValueError, match="canonical truth"): + validate_project_guide_compilation_result(context, invalid) + + +def test_platform_coverage_rejects_disabled_platform_capability() -> None: + context = _context().model_copy( + update={ + "pre_submission_capabilities": project_guide_pre_submission_capabilities( + build_pre_submission_checker_catalogue( + disabled_entry_ids=frozenset({"artifact.outer_zip.valid"}) + ) + ) + } + ) + result = ProjectGuideCompilationResult( + status="draft_ready", + submission_artifact_policy=_artifact_policy(), + requirements=( + AtomicGuideRequirement( + requirement_id="requirement.zip", + statement="Validate the outer ZIP.", + disposition="platform_covered", + platform_coverage=PlatformCoverageRef( + capability_id="artifact.outer_zip.valid", + capability_version="v1", + stage="pre_submit", + ), + ), + ), + agent_version="v1", + ) + with pytest.raises(ValueError, match="canonical truth"): + validate_project_guide_compilation_result(context, result) + + +def test_platform_coverage_accepts_exact_post_submit_default() -> None: + result = ProjectGuideCompilationResult( + status="draft_ready", + submission_artifact_policy=_artifact_policy(), + requirements=( + AtomicGuideRequirement( + requirement_id="requirement.packet", + statement="Run the platform submission-packet check.", + disposition="platform_covered", + platform_coverage=PlatformCoverageRef( + capability_id="check_submission_packet", + capability_version=POST_SUBMIT_COMPILER_VERSION, + stage="post_submit", + ), + ), + ), + agent_version="v1", + ) + validate_project_guide_compilation_result(_context(), result) + + +def test_platform_coverage_ref_is_required_only_for_platform_disposition() -> None: + missing = ProjectGuideCompilationResult( + status="draft_ready", + submission_artifact_policy=_artifact_policy(), + requirements=( + AtomicGuideRequirement( + requirement_id="requirement.coverage", + statement="Require canonical coverage proof.", + disposition="platform_covered", + ), + ), + agent_version="v1", + ) + with pytest.raises(ValueError, match="requires canonical proof"): + validate_project_guide_compilation_result(_context(), missing) + + misplaced = ProjectGuideCompilationResult( + status="draft_ready", + submission_artifact_policy=_artifact_policy(), + requirements=( + AtomicGuideRequirement( + requirement_id="requirement.info", + statement="Record an informational requirement.", + disposition="informational", + platform_coverage=PlatformCoverageRef( + capability_id="artifact.outer_zip.valid", + capability_version="v1", + stage="pre_submit", + ), + ), + ), + agent_version="v1", + ) + with pytest.raises(ValueError, match="cannot claim platform coverage"): + validate_project_guide_compilation_result(_context(), misplaced) + + +def test_blocked_result_cannot_publish_policy_or_bindings() -> None: + context = _context() + result = ProjectGuideCompilationResult( + status="guide_blocked", + findings=( + CompilationFinding( + severity="blocking_gap", + code="guide.blocked", + message="Guide has a blocking gap.", + ), + ), + submission_artifact_policy=_artifact_policy(), + agent_version="v1", + ) + with pytest.raises(ValueError, match="blocked guide"): + validate_project_guide_compilation_result(context, result) From 52782044f3f3ddf202561c4a03a7451999d81637 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 8 Aug 2026 11:58:41 +0100 Subject: [PATCH 2/4] Restore project agent docstring coverage --- backend/app/interfaces/project_agents.py | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/backend/app/interfaces/project_agents.py b/backend/app/interfaces/project_agents.py index 31ad2ead6..b64df7ead 100644 --- a/backend/app/interfaces/project_agents.py +++ b/backend/app/interfaces/project_agents.py @@ -43,23 +43,29 @@ def _validated_safe_model_text(value: str) -> str: + """Reject unsafe or unbounded model-produced operator text.""" if not value or len(value) > 1000 or _UNSAFE_MODEL_TEXT.search(value): raise ValueError("model-produced text is unsafe") return value def _validated_identifier(value: str) -> str: + """Require one bounded canonical identifier.""" if not _SAFE_IDENTIFIER.fullmatch(value): raise ValueError("identifier is invalid") return value class CompilationStage(StrEnum): + """Closed checker stages available to unified compilation.""" + PRE_SUBMIT = "pre_submit" POST_SUBMIT = "post_submit" class RequirementDisposition(StrEnum): + """Closed trusted classifications for one atomic guide requirement.""" + PLATFORM_COVERED = "platform_covered" SUPPORTED_PRE_SUBMIT = "supported_pre_submit" PRE_SUBMIT_CAPABILITY_GAP = "pre_submit_capability_gap" @@ -155,11 +161,13 @@ class RepresentativeTaskPolicyContext(BaseModel): @field_validator("task_kind") @classmethod def validate_task_kind(cls, value: str) -> str: + """Require a canonical redacted task-kind identifier.""" return _validated_identifier(value) @field_validator("deliverable_kinds", "required_evidence_kinds") @classmethod def validate_task_identifiers(cls, values: tuple[str, ...]) -> tuple[str, ...]: + """Require unique canonical task policy identifiers.""" if len(values) != len(set(values)): raise ValueError("task policy identifiers must be unique") return tuple(_validated_identifier(value) for value in values) @@ -178,6 +186,7 @@ class GuideEvidenceRef(BaseModel): @model_validator(mode="after") def validate_ordinals(self) -> GuideEvidenceRef: + """Require a non-empty ordered evidence range.""" if self.end_ordinal <= self.start_ordinal: raise ValueError("evidence ordinals are invalid") return self @@ -209,6 +218,7 @@ class VerifiedGuideMaterialSnapshot(BaseModel): @model_validator(mode="after") def validate_snapshot_integrity(self) -> VerifiedGuideMaterialSnapshot: + """Bind the immutable payload to its hash and unique lineage.""" expected_hash = "sha256:" + hashlib.sha256(self.canonical_payload).hexdigest() if self.canonical_payload_sha256 != expected_hash: raise ValueError("canonical guide material hash is invalid") @@ -221,6 +231,7 @@ def validate_snapshot_integrity(self) -> VerifiedGuideMaterialSnapshot: @classmethod def from_material(cls, material: GuideSourceMaterial) -> VerifiedGuideMaterialSnapshot: + """Snapshot exact verified material after rejecting legacy open shapes.""" if not material.verified_artifact_material: raise ValueError("compilation requires ART-verified guide material") if material.representative_task_material.items: @@ -270,6 +281,7 @@ class CapabilityParameter(BaseModel): @field_validator("name") @classmethod def validate_name(cls, value: str) -> str: + """Require a canonical catalogue-owned parameter name.""" return _validated_identifier(value) @field_validator("value") @@ -277,6 +289,7 @@ def validate_name(cls, value: str) -> str: def validate_value( cls, value: CapabilityScalar | tuple[CapabilityScalar, ...] ) -> CapabilityScalar | tuple[CapabilityScalar, ...]: + """Reject nested, non-finite, executable, or unbounded parameter values.""" values = value if isinstance(value, tuple) else (value,) if not values or len(values) > 50: raise ValueError("capability parameter value is invalid") @@ -305,10 +318,12 @@ class CapabilityBindingProposal(BaseModel): @field_validator("requirement_id", "capability_id", "capability_version") @classmethod def validate_identifiers(cls, value: str) -> str: + """Require canonical requirement and capability identity fields.""" return _validated_identifier(value) @model_validator(mode="after") def validate_parameter_names(self) -> CapabilityBindingProposal: + """Reject duplicate parameter names within one binding.""" names = [parameter.name for parameter in self.parameters] if len(names) != len(set(names)): raise ValueError("capability parameters must be unique") @@ -316,6 +331,8 @@ def validate_parameter_names(self) -> CapabilityBindingProposal: class CompilationFinding(BaseModel): + """One bounded operator-visible finding from unified compilation.""" + model_config = ConfigDict(extra="forbid", frozen=True) severity: Literal["blocking_gap", "warning", "info"] @@ -344,6 +361,8 @@ class PlatformCoverageRef(BaseModel): class AtomicGuideRequirement(BaseModel): + """One evidence-linked guide requirement with one disposition.""" + model_config = ConfigDict(extra="forbid", frozen=True) requirement_id: str @@ -359,6 +378,8 @@ class AtomicGuideRequirement(BaseModel): class SubmissionArtifactPolicyProposal(BaseModel): + """Closed submission artifact policy proposed by compilation.""" + model_config = ConfigDict(extra="forbid", frozen=True) packaging: Literal["zip"] = "zip" @@ -375,12 +396,14 @@ class SubmissionArtifactPolicyProposal(BaseModel): ) @classmethod def validate_policy_text(cls, values: tuple[str, ...]) -> tuple[str, ...]: + """Reject duplicate or unsafe artifact policy text.""" if len(values) != len(set(values)): raise ValueError("artifact policy values must be unique") return tuple(_validated_safe_model_text(value) for value in values) @model_validator(mode="after") def validate_package_limit(self) -> SubmissionArtifactPolicyProposal: + """Require coherent file/package limits and artifact sets.""" if self.maximum_file_size_bytes > self.maximum_package_size_bytes: raise ValueError("file limit exceeds package limit") if set(self.required_artifacts).intersection(self.forbidden_artifacts): @@ -389,6 +412,8 @@ def validate_package_limit(self) -> SubmissionArtifactPolicyProposal: class CapabilitySuggestion(BaseModel): + """Non-executable engineering suggestion for a capability gap.""" + model_config = ConfigDict(extra="forbid", frozen=True) title: str @@ -448,6 +473,7 @@ class ProjectGuideCompilationResult(BaseModel): @field_validator("setup_notes") @classmethod def validate_notes(cls, values: tuple[str, ...]) -> tuple[str, ...]: + """Reject unsafe setup notes before trusted validation.""" return tuple(_validated_safe_model_text(value) for value in values) _agent_version = field_validator("agent_version")(_validated_identifier) @@ -503,6 +529,7 @@ def validate_project_guide_compilation_result( def _validate_status_consistency(result: ProjectGuideCompilationResult) -> None: + """Require ready and blocked status to match findings and dispositions.""" has_blocker = any(finding.severity == "blocking_gap" for finding in result.findings) or any( requirement.disposition in { @@ -528,6 +555,7 @@ def _validate_platform_coverage( pre_definitions: dict[str, PreSubmissionCapabilityDefinition], post_definitions: dict[str, PostSubmissionCapabilityDefinition], ) -> None: + """Resolve platform coverage only against eligible phase-owner truth.""" for requirement in requirements: coverage = requirement.platform_coverage if requirement.disposition is not RequirementDisposition.PLATFORM_COVERED: @@ -562,6 +590,7 @@ def _validate_evidence_lineage( context: ProjectGuideCompilationContext, result: ProjectGuideCompilationResult, ) -> None: + """Resolve every model evidence reference to immutable source lineage.""" source_lineage = { ( str(item.source_item_id), @@ -591,6 +620,7 @@ def _validate_bindings( definitions: dict[str, PreSubmissionCapabilityDefinition | PostSubmissionCapabilityDefinition], expected_stage: Literal["pre_submit", "post_submit"], ) -> set[str]: + """Validate exact stage, version, selectability, and parameter ownership.""" seen_requirements: set[str] = set() expected_disposition = ( RequirementDisposition.SUPPORTED_PRE_SUBMIT From 9dd1a827c1807d182b67d51b83608e2134d480d0 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 8 Aug 2026 12:02:34 +0100 Subject: [PATCH 3/4] Assign compilation contracts to test lane --- .../chunks/WS-POL-003-01-contract-catalogue-projection.md | 5 +++++ backend/scripts/run_test_lanes.py | 1 + backend/tests/test_ci_test_lanes.py | 1 + 3 files changed, 7 insertions(+) diff --git a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-01-contract-catalogue-projection.md b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-01-contract-catalogue-projection.md index 80b1f059a..06162da4a 100644 --- a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-01-contract-catalogue-projection.md +++ b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-01-contract-catalogue-projection.md @@ -21,6 +21,8 @@ second POL-owned registry. backend/app/interfaces/project_agents.py backend/app/modules/checkers/catalogue.py backend/app/modules/projects/post_submit_policy.py +backend/scripts/run_test_lanes.py +backend/tests/test_ci_test_lanes.py backend/tests/test_project_guide_compilation_contracts.py .agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/** ``` @@ -29,6 +31,9 @@ backend/tests/test_project_guide_compilation_contracts.py functions only. Their existing registry, definitions, defaults, compiler, validation, hashing, and execution semantics are frozen. +The CI lane files may change only to assign the new test module exactly once +to the existing task-lifecycle lane and update its exact-inventory assertion. + ## Not allowed ```text diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py index fbc3940c2..502c23b83 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -165,6 +165,7 @@ class TestLane: "tests/test_checkers.py", "tests/test_default_pre_submit_execution.py", "tests/test_effective_pre_submit_execution.py", + "tests/test_project_guide_compilation_contracts.py", "tests/test_review_queue_persistence.py", "tests/test_review_lease_persistence.py", "tests/test_tasks.py", diff --git a/backend/tests/test_ci_test_lanes.py b/backend/tests/test_ci_test_lanes.py index 33dd821cc..423c0c9c7 100644 --- a/backend/tests/test_ci_test_lanes.py +++ b/backend/tests/test_ci_test_lanes.py @@ -46,6 +46,7 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: "tests/test_checkers.py", "tests/test_default_pre_submit_execution.py", "tests/test_effective_pre_submit_execution.py", + "tests/test_project_guide_compilation_contracts.py", "tests/test_review_lease_persistence.py", "tests/test_review_queue_persistence.py", "tests/test_tasks.py", From e1a7f41e3d138b5954d4fd55f60110506bda0e9f Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 8 Aug 2026 12:33:46 +0100 Subject: [PATCH 4/4] Harden guide compilation validation --- ...OL-003-01-contract-catalogue-projection.md | 8 +- .../WS-POL-003-01-internal-review-evidence.md | 15 +- backend/app/interfaces/project_agents.py | 9 +- .../modules/projects/post_submit_policy.py | 18 +- ...est_project_guide_compilation_contracts.py | 163 ++++++++++++++++-- 5 files changed, 186 insertions(+), 27 deletions(-) diff --git a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-01-contract-catalogue-projection.md b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-01-contract-catalogue-projection.md index 06162da4a..3e7be5497 100644 --- a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-01-contract-catalogue-projection.md +++ b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/chunks/WS-POL-003-01-contract-catalogue-projection.md @@ -105,9 +105,11 @@ validator. This chunk does not change the historical post-submit compiler. registered names, frozen default names, stage, and selectability. - Capability identity is the registered checker name; capability version is the frozen source/compiler version; stage is exactly `post_submit`. -- The eight frozen v0.1 defaults are non-selectable platform coverage. The - registered-minus-default set is project-selectable; on current main it is - exactly `check_acceptance_criteria_present`. +- The eight frozen v0.1 defaults are non-selectable platform coverage. A + separate frozen compiler-version snapshot explicitly owns project-selectable + capabilities; it is disjoint from the defaults and currently contains only + `check_acceptance_criteria_present`. Registered but unlisted capabilities are + neither default nor selectable. - Default/unknown/wrong-stage/stale-snapshot bindings fail in unified proposal validation. No registry or compiler mutation occurs. diff --git a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-01-internal-review-evidence.md b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-01-internal-review-evidence.md index c4203c3e5..d9f575380 100644 --- a/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-01-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-POL-003-unified-project-guide-compilation/reviews/WS-POL-003-01-internal-review-evidence.md @@ -5,7 +5,7 @@ Date: 2026-08-08. Risk: L1. ## Deterministic evidence - Scoped Ruff: passed. -- Focused non-database tests: 66 passed. +- Focused non-database tests: 91 passed after external-review corrections. - Changed-subsystem coverage with the neighboring checker tests reached above 90 percent; database-backed completion remains assigned to the hosted Backend lane because it supplies Postgres and `WORKSTREAM_TEST_DATABASE_URL`. @@ -37,5 +37,18 @@ Date: 2026-08-08. Risk: L1. - Docs: pass; the active plan now distinguishes POL-01 context fields from later correction/persistence fields. +## External-review correction re-review + +- Architecture: pass after explicit post-submit selectability, default/selectable + disjointness, and matching chunk-contract wording were added. +- Security: pass after bare value-shaped credential forms, complete non-empty + ART lineage, unavailable mandatory pre-submit coverage, and closed + post-submit selectability were enforced. +- QA: pass; all six CodeRabbit findings have regression proof. +- Senior engineering: pass after credential detection was narrowed to preserve + ordinary security-policy language. +- Test delta: pass after missing selectable-registration and snapshot-overlap + regressions were added; no tests were removed or skipped. + All blocking findings were corrected and re-reviewed. No reviewer session remains open. diff --git a/backend/app/interfaces/project_agents.py b/backend/app/interfaces/project_agents.py index b64df7ead..0461c9520 100644 --- a/backend/app/interfaces/project_agents.py +++ b/backend/app/interfaces/project_agents.py @@ -35,8 +35,9 @@ r"[\x00-\x08\x0b\x0c\x0e-\x1f]|(?:https?|file|data|ssh)://|" r"(?:^|\s)(?:/|\\\\|\.\.?/|[a-z]:\\|[\w.-]+/[\w./-]+)|" r"\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b|" - r"\b(?:password|secret|credential|bearer|" - r"api[_ -]?key|token)\b\s*[:=]|\brequire\s*\(|\b(?:import|pip install|npm install|" + r"\b(?:bearer\s+\S+|(?:password|secret|credential|api[_ -]?key|token)" + r"\b\s*(?:[:=]\s*\S+|\s+(?=\S*(?:\d|[-_=+/]))\S+))|" + r"\brequire\s*\(|\b(?:import|pip install|npm install|" r"curl|wget|powershell|bash|sh)\b", re.IGNORECASE, ) @@ -252,6 +253,8 @@ def from_material(cls, material: GuideSourceMaterial) -> VerifiedGuideMaterialSn for item in material.source_items if item.source_item_id and item.extraction_usage_id and item.canonical_output_sha256 ) + if not lineage or len(lineage) != len(material.source_items): + raise ValueError("compilation material requires complete source lineage") return cls( project_id=material.project_id, guide_id=material.guide_id, @@ -484,6 +487,8 @@ def validate_project_guide_compilation_result( result: ProjectGuideCompilationResult, ) -> None: """Fail closed when an untrusted result diverges from canonical capability truth.""" + if not context.pre_submission_capabilities.available: + raise ValueError("pre-submit capability projection is unavailable") requirements = {item.requirement_id: item for item in result.requirements} if len(requirements) != len(result.requirements): raise ValueError("compilation requirements must be unique") diff --git a/backend/app/modules/projects/post_submit_policy.py b/backend/app/modules/projects/post_submit_policy.py index b5b7ed8e0..54e324ad4 100644 --- a/backend/app/modules/projects/post_submit_policy.py +++ b/backend/app/modules/projects/post_submit_policy.py @@ -54,6 +54,12 @@ POST_SUBMIT_COMPILER_VERSION: POST_SUBMIT_V01_DEFAULT_CHECKERS, } ) +POST_SUBMIT_V01_SELECTABLE_CHECKERS = ("check_acceptance_criteria_present",) +POST_SUBMIT_SELECTABLE_CHECKERS_BY_COMPILER_VERSION = MappingProxyType( + { + POST_SUBMIT_COMPILER_VERSION: POST_SUBMIT_V01_SELECTABLE_CHECKERS, + } +) SUPPORTED_POST_SUBMIT_COMPILER_VERSIONS = frozenset( POST_SUBMIT_DEFAULT_CHECKERS_BY_COMPILER_VERSION ) @@ -79,18 +85,24 @@ def project_guide_post_submission_capabilities( ) -> PostSubmissionCapabilityProjection: """Project registered CHECKER truth and frozen defaults without a new registry.""" defaults = _default_checkers_for_compiler_version(compiler_version) + selectable = POST_SUBMIT_SELECTABLE_CHECKERS_BY_COMPILER_VERSION.get(compiler_version) + if selectable is None: + raise PostSubmitCheckerCompilerError("unsupported post-submit compiler version") registered = tuple(sorted(default_checker_registry().names())) default_set = frozenset(defaults) - if not default_set.issubset(registered): + selectable_set = frozenset(selectable) + if default_set & selectable_set: raise PostSubmitCheckerCompilerError( - "post-submit default checker registration parity is invalid" + "post-submit default and selectable checker snapshots overlap" ) + if not (default_set | selectable_set).issubset(registered): + raise PostSubmitCheckerCompilerError("post-submit checker registration parity is invalid") definitions = tuple( PostSubmissionCapabilityDefinition( capability_id=name, capability_version=compiler_version, platform_default=name in default_set, - selectable=name not in default_set, + selectable=name in selectable_set, ) for name in registered ) diff --git a/backend/tests/test_project_guide_compilation_contracts.py b/backend/tests/test_project_guide_compilation_contracts.py index b3b42fa13..008016d27 100644 --- a/backend/tests/test_project_guide_compilation_contracts.py +++ b/backend/tests/test_project_guide_compilation_contracts.py @@ -96,7 +96,7 @@ def test_pre_submission_projection_preserves_exact_manifest_and_selectability() projection = project_guide_pre_submission_capabilities(catalogue) assert projection.manifest_sha256 == catalogue.manifest_sha256 - assert len(projection.definitions) == 26 + assert len(projection.definitions) == len(catalogue.entries) assert [item.stable_id for item in projection.definitions] == [ item.stable_id for item in catalogue.entries ] @@ -131,6 +131,28 @@ def test_pre_submission_projection_reports_disabled_mandatory_unavailable() -> N assert projection.definitions[0].selectable is False +def test_compilation_rejects_unavailable_mandatory_pre_submission_projection() -> None: + context = _context().model_copy( + update={ + "pre_submission_capabilities": project_guide_pre_submission_capabilities( + build_pre_submission_checker_catalogue( + disabled_entry_ids=frozenset({"artifact.outer_zip.valid"}) + ) + ) + } + ) + result = ProjectGuideCompilationResult( + status="draft_ready", + findings=( + CompilationFinding(severity="info", code="guide.ready", message="Guide is complete."), + ), + submission_artifact_policy=_artifact_policy(), + agent_version="v1", + ) + with pytest.raises(ValueError, match="projection is unavailable"): + validate_project_guide_compilation_result(context, result) + + def test_post_submission_projection_uses_registry_and_frozen_default_truth() -> None: projection = project_guide_post_submission_capabilities() by_name = {item.capability_id: item for item in projection.definitions} @@ -146,6 +168,57 @@ def test_post_submission_projection_uses_registry_and_frozen_default_truth() -> assert all(item.stage == "post_submit" for item in projection.definitions) +def test_post_submission_projection_does_not_select_new_registration_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class ExpandedRegistry: + def names(self) -> set[str]: + return { + *POST_SUBMIT_V01_DEFAULT_CHECKERS, + "check_acceptance_criteria_present", + "check_experimental_internal", + } + + monkeypatch.setattr( + "app.modules.projects.post_submit_policy.default_checker_registry", + ExpandedRegistry, + ) + projection = project_guide_post_submission_capabilities() + experimental = next( + item + for item in projection.definitions + if item.capability_id == "check_experimental_internal" + ) + assert experimental.platform_default is False + assert experimental.selectable is False + + +def test_post_submission_projection_rejects_missing_selectable_registration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class DefaultsOnlyRegistry: + def names(self) -> set[str]: + return set(POST_SUBMIT_V01_DEFAULT_CHECKERS) + + monkeypatch.setattr( + "app.modules.projects.post_submit_policy.default_checker_registry", + DefaultsOnlyRegistry, + ) + with pytest.raises(PostSubmitCheckerCompilerError, match="registration parity"): + project_guide_post_submission_capabilities() + + +def test_post_submission_projection_rejects_default_selectable_overlap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "app.modules.projects.post_submit_policy.POST_SUBMIT_SELECTABLE_CHECKERS_BY_COMPILER_VERSION", + {POST_SUBMIT_COMPILER_VERSION: (POST_SUBMIT_V01_DEFAULT_CHECKERS[0],)}, + ) + with pytest.raises(PostSubmitCheckerCompilerError, match="snapshots overlap"): + project_guide_post_submission_capabilities() + + def test_post_submission_projection_rejects_unknown_compiler_snapshot() -> None: with pytest.raises(PostSubmitCheckerCompilerError): project_guide_post_submission_capabilities(compiler_version="unknown") @@ -183,6 +256,12 @@ def test_guide_evidence_ref_rejects_non_lineage_fields(payload: dict[str, str], "https://example.invalid/instructions", "/etc/passwd", "token=secret", + "Bearer opaque-token", + "password hunter2", + "secret hidden-value", + "credential private-value", + "api key private-value", + "token opaque-value", "import subprocess", "curl example.invalid", "see docs/guide.md", @@ -196,6 +275,19 @@ def test_model_produced_text_rejects_unsafe_shapes(unsafe: str) -> None: CapabilitySuggestion(title="new checker", rationale=unsafe) +@pytest.mark.parametrize( + "safe", + [ + "Require credential handling attestation.", + "Document token rotation requirements.", + "Avoid password storage in submissions.", + ], +) +def test_model_produced_text_allows_safe_security_policy_language(safe: str) -> None: + suggestion = CapabilitySuggestion(title="security guidance", rationale=safe) + assert suggestion.rationale == safe + + def test_representative_task_context_is_optional_and_rejects_pii_fields() -> None: context = _context() assert context.representative_task is None @@ -253,17 +345,27 @@ def test_unified_result_accepts_exact_stage_capability_and_closed_parameters() - @pytest.mark.parametrize( - ("capability_id", "version", "stage"), + ("capability_id", "version", "stage", "expected_error"), [ - ("submission.packet.required_fields", "v1", "pre_submit"), - ("policy.submission_packet.validate", "stale", "pre_submit"), - ("unknown.capability", "v1", "pre_submit"), - ("check_submission_packet", POST_SUBMIT_COMPILER_VERSION, "post_submit"), - ("check_acceptance_criteria_present", POST_SUBMIT_COMPILER_VERSION, "pre_submit"), + ("submission.packet.required_fields", "v1", "pre_submit", "binding is invalid"), + ("policy.submission_packet.validate", "stale", "pre_submit", "version is stale"), + ("unknown.capability", "v1", "pre_submit", "binding is invalid"), + ( + "check_submission_packet", + POST_SUBMIT_COMPILER_VERSION, + "post_submit", + "binding is invalid", + ), + ( + "check_acceptance_criteria_present", + POST_SUBMIT_COMPILER_VERSION, + "pre_submit", + "binding is invalid", + ), ], ) def test_unified_result_rejects_default_unknown_stale_and_wrong_stage_bindings( - capability_id: str, version: str, stage: str + capability_id: str, version: str, stage: str, expected_error: str ) -> None: context = _context() disposition = "supported_post_submit" if stage == "post_submit" else "supported_pre_submit" @@ -287,7 +389,7 @@ def test_unified_result_rejects_default_unknown_stale_and_wrong_stage_bindings( post_submit_bindings=(binding,) if stage == "post_submit" else (), agent_version="v1", ) - with pytest.raises(ValueError, match="capability binding|version"): + with pytest.raises(ValueError, match=expected_error): validate_project_guide_compilation_result(context, result) @@ -363,6 +465,26 @@ def test_context_rejects_unverified_or_unredacted_legacy_material() -> None: ) ) + with pytest.raises(ValueError, match="complete source lineage"): + VerifiedGuideMaterialSnapshot.from_material( + GuideSourceMaterial(**base, verified_artifact_material=True) + ) + + with pytest.raises(ValueError, match="complete source lineage"): + VerifiedGuideMaterialSnapshot.from_material( + GuideSourceMaterial( + **base, + verified_artifact_material=True, + source_items=[ + { + "source_kind": "uploaded_file", + "ingestion_adapter": "artifact_store", + "source_item_id": str(SOURCE_ITEM_ID), + } + ], + ) + ) + def test_compilation_material_snapshot_cannot_drift_after_validation() -> None: snapshot = _context().material @@ -453,17 +575,22 @@ def test_capability_binding_rejects_unowned_parameters(stage: str) -> None: @pytest.mark.parametrize( - ("status", "finding_severity", "disposition"), + ("status", "finding_severity", "disposition", "expected_error"), [ - ("draft_ready", "blocking_gap", "informational"), - ("draft_ready", "info", "guide_blocker"), - ("draft_ready", "warning", "informational"), - ("draft_ready_with_warnings", "info", "informational"), - ("guide_blocked", "info", "informational"), + ("draft_ready", "blocking_gap", "informational", "cannot contain blocking"), + ("draft_ready", "info", "guide_blocker", "cannot contain blocking"), + ("draft_ready", "warning", "informational", "cannot contain warnings"), + ( + "draft_ready_with_warnings", + "info", + "informational", + "requires a warning", + ), + ("guide_blocked", "info", "informational", "requires blocking evidence"), ], ) def test_result_status_must_match_findings_and_blocking_dispositions( - status: str, finding_severity: str, disposition: str + status: str, finding_severity: str, disposition: str, expected_error: str ) -> None: result = ProjectGuideCompilationResult( status=status, @@ -484,7 +611,7 @@ def test_result_status_must_match_findings_and_blocking_dispositions( ), agent_version="v1", ) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match=expected_error): validate_project_guide_compilation_result(_context(), result) @@ -561,7 +688,7 @@ def test_platform_coverage_rejects_disabled_platform_capability() -> None: ), agent_version="v1", ) - with pytest.raises(ValueError, match="canonical truth"): + with pytest.raises(ValueError, match="projection is unavailable"): validate_project_guide_compilation_result(context, result)