diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md index 1550d7135..7997344af 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/CHUNK_MAP.md @@ -24,13 +24,13 @@ they cross multiple L1 boundaries. |---|---|---:|---| | `WS-ART-001-PLAN3` | Reconcile the complete remaining v0.1 custody chain and AUTH/REV/CON handoffs. | L1 | Merged planning | | `WS-ART-001-PLAN4` | Define the central default pre-submission checker catalogue, disable semantics, and split execution contract. | L1 | Merged PR #271 | -| `WS-ART-001-PLAN5` | Correct legacy-precheck removal sequencing so the old public and internal paths are deleted only with the admission-backed Submission cutover. | L1 | Planning complete; PR pending | +| `WS-ART-001-PLAN5` | Correct legacy-precheck removal sequencing so the old public and internal paths are deleted only with the admission-backed Submission cutover. | L1 | Merged PR #273 | | `WS-ART-001-03C` | Clean-cut legacy guide identity/excerpts and make the verified same-generation pipeline live. | L1 | Merged PR #249 | | `WS-ART-001-04A1` | Remove legacy multi-step contributor intake reachability and schema without adding the replacement route. | L1 | Merged PR #264 | | `WS-ART-001-04A2` | Add bounded one-outer-ZIP intake and archive-safety inspection in private scratch. | L1 | Merged PR #266 | | `WS-ART-001-04A3` | Add canonical semantic manifest, executable normalization, and unchanged-work gate. | L1 | Merged PR #268 | | `WS-ART-001-04A4` | Former early removal of the legacy independently invocable caller-owned submission-precheck route and contract. | L1 | Superseded by PLAN5; complete removal belongs to 05B | -| `WS-ART-001-04B1` | Add the single versioned checker catalogue and compile one effective execution plan from platform defaults plus locked project policy. | L1 | Proposed after PLAN5 | +| `WS-ART-001-04B1` | Add the single versioned checker catalogue and compile one effective execution plan from platform defaults plus locked project policy. | L1 | Active implementation | | `WS-ART-001-04B2` | Materialize the sealed manifest tree once and execute the mandatory platform/default catalogue phases. | L1 | Proposed after 04B1 | | `WS-ART-001-04B3` | Execute locked project-policy rules through the same plan and persist one bounded immutable evidence set. | L1 | Proposed after 04B2 | | `WS-ART-001-04C1` | Reauthorize and atomically persist capacity plus durable put intent, then write the checked ZIP once. | L1 | Proposed after XINT-06A | diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md index 0a85f1945..35c28d131 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md @@ -119,12 +119,23 @@ normalization, and fail-closed unchanged-work comparison. `WS-ART-001-PLAN4` merged through PR #271. Discovery for its proposed 04A4 implementation proved the early clean cut unsafe because live legacy Submission creation still calls the shared precheck service and the verified-admission -replacement is not yet available. PLAN5 is the active planning correction: it +replacement is not yet available. PLAN5 merged through PR #273: it supersedes 04A4, makes 04B1 the next implementation chunk, and assigns complete legacy route/public-service/internal-guard/caller-package removal to the 05B -admission-backed Submission cutover. All required internal L1 reviews pass after -repair; local documentation and agent gates pass. No runtime behavior or AUTH -availability changes in PLAN5; hosted PR review remains pending. +admission-backed Submission cutover. + +`WS-ART-001-04B1` is active on its bounded worktree. It owns only the immutable +typed catalogue, startup-fixed availability validation, migration of the +existing compiler away from parallel primitive maps, and pure composition of +one lineage-bound effective plan. It performs no ZIP read, scratch +materialization, checker execution, durable write, route exposure, or AUTH +activation. + +The implementation and deterministic evidence are complete in draft PR #276. +All required internal reviewer tracks ran successfully after their valid +findings were repaired. CodeRabbit completed substantive review and its two +actionable threads were repaired and resolved. The ready PR now waits only for +the final hosted Backend rerun on the repaired head. ## Gate diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B1-default-checker-catalogue.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B1-default-checker-catalogue.md index e998d1571..209a4b8e7 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B1-default-checker-catalogue.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B1-default-checker-catalogue.md @@ -1,6 +1,6 @@ # Chunk Contract: WS-ART-001-04B1 - Default Checker Catalogue -Initiative: `WS-ART-001` | Risk: L1 | Status: Proposed after PLAN5 +Initiative: `WS-ART-001` | Risk: L1 | Status: Ready PR pending final hosted Backend rerun Artifact contract phase: `upload_admission` diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B3-effective-pre-submit-evidence.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B3-effective-pre-submit-evidence.md index fae0615d1..bc63a5a91 100644 --- a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B3-effective-pre-submit-evidence.md +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/chunks/WS-ART-001-04B3-effective-pre-submit-evidence.md @@ -31,7 +31,8 @@ Submission, or separate contributor route. ## Acceptance Criteria - one ordered result contains both platform/default and locked project entries, - each with catalogue ID/version, source, status, severity, bounded code/message, + each with stable catalogue definition ID/version, source, status, severity, + bounded code/message, and policy trace; - the canonical typed result envelope nests identity under `definition` (`dispatch_authority`, authority-neutral definition ID/version, public name, @@ -39,7 +40,9 @@ Submission, or separate contributor route. under `policy_trace` (effective-plan hash, deterministic rule-instance ID, locked-policy hash); immutable evidence persists each member explicitly and never relies on open-ended `metadata` for required provenance; for this - pre-submit authority, definition ID/version are exactly catalogue ID/version; + pre-submit authority, definition ID/version are exactly the stable catalogue + definition ID/version; the effective plan separately binds the top-level + catalogue ID/version and manifest hash; - execution binds actor/task/project/assignment, predecessor, archive identity, manifest ID/hash, scratch generation, locked guide/policy/checker hashes, and effective plan identity; diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B1-internal-review-evidence.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B1-internal-review-evidence.md new file mode 100644 index 000000000..6bca5098a --- /dev/null +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B1-internal-review-evidence.md @@ -0,0 +1,51 @@ +# WS-ART-001 04B1 Internal Review Evidence + +Reviewed change: single pre-submission catalogue and effective-plan compiler + +## Preimplementation Review + +| Track | Result | Incorporated conditions | +|---|---|---| +| Architecture | PASS WITH CONDITIONS | Catalogue replaces compiler maps and durable-registry authority; platform capabilities remain typed references; no fallback constructor. | +| Security/auth | PASS WITH CONDITIONS | Full locked lineage and domain-separated plan identity; explicit startup-owned catalogue; mandatory disabled fails closed. | +| Product/ops | PASS WITH CONDITIONS | Mandatory disabled is infrastructure-unavailable; legacy route remains frozen; no downstream lifecycle effects. | + +## Final Implementation Review + +| Track | Final result | Resolution evidence | +|---|---|---| +| Architecture | PASS | No boundary, abstraction, coupling, or chunk-scope violation. | +| Security/auth | PASS after repair | Locked policy body hash and complete compiled-rule coverage now fail closed. | +| QA/test | PASS after repair | Omitted required rules and weakened policy bodies have regression tests. | +| Product/ops | PASS after repair | Rule-instance identity binds catalogue ID, version, and manifest. | +| Senior engineering | PASS after repair | Removed phase-order duplication; fixed startup test input and policy validation. | +| CI integrity | PASS | New 90 percent checker gate strengthens CI; canonical five-lane evidence remains intact. | +| Reuse/dedup | PASS after repair | Stable IDs are unique across versions; no alternate pre-submit authority remains. | +| Test delta | PASS after repair | Exact 26-row catalogue contract is locked; no tests removed, skipped, or weakened. | +| Docs | PASS WITH LOW RISKS after repair | Definition/top-level catalogue identity and five plan phases are now explicit. | + +The reviewer authentication outage was transient. Every required track later +ran against the repaired PR and all blocking findings were resolved. + +## Deterministic Evidence + +```text +21 catalogue/effective-plan tests: pass +42 focused catalogue/compiler tests after external-review repairs: pass +new-module coverage: above the hosted 90 percent subsystem gate +focused compiler/catalogue selector: pass +163 database-free checker tests: pass +25 database-backed checker tests: not run locally; test database URL absent +ruff app/tests: pass +compileall changed Python: pass +git diff --check: pass +stale artifact contract scan: pass +stale Workstream wording scan: pass +Markdown links: pass +lightweight agent gates: pass +hosted five-lane Backend plus aggregate coverage at bb04677c: pass +``` + +CodeRabbit completed a substantive review. Its two actionable threads were +repaired and are resolved; its incremental follow-up was rate-limited. The final +repaired head still requires its hosted Backend rerun. diff --git a/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B1-pr-trust-bundle.md b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B1-pr-trust-bundle.md new file mode 100644 index 000000000..5be6bd7b1 --- /dev/null +++ b/.agent-loop/initiatives/WS-ART-001-immutable-artifact-storage/reviews/WS-ART-001-04B1-pr-trust-bundle.md @@ -0,0 +1,130 @@ +# WS-ART-001 04B1 PR Trust Bundle + +## Chunk + +`WS-ART-001-04B1` — Default Checker Catalogue + +## Goal + +Install the single typed, versioned Workstream pre-submission catalogue and +compile one immutable effective plan from platform defaults plus the exact +locked project policy. + +## Human-Approved Intent + +ART owns the generic platform defaults and the single composition mechanism. +The Project Guide workflow continues to generate and lock project-specific +policy. No second API or registry is allowed. + +## What Changed + +- added 26 closed platform-capability and policy-primitive definitions; +- added startup-owned enabled/disabled configuration and validation; +- replaced compiler-local primitive/name/policy-field maps and removed the + durable checker registry as pre-submit compiler authority; +- added a pure effective-plan compiler with exact lineage, catalogue manifest, + definition/configuration hashes, locked policy-body validation, complete-rule + coverage validation, and catalogue-bound deterministic rule-instance identity; +- added focused tests and a non-weakening hosted 90 percent checker coverage + gate; +- reconciled merged PLAN5 and active 04B1 status. + +## Why It Changed + +Scattered maps and the durable checker registry could become alternate +pre-submit authorities. Workstream needs one discoverable catalogue where +mandatory disabled state fails closed and locked project policy can only add or +narrow requirements through constrained definitions. + +## Design Chosen + +Frozen dataclasses and closed enums define catalogue identity, classification, +phase/dependencies, typed inputs, dispatch capability, result schema, budget, +policy trace, and disabled behavior. The effective-plan compiler requires the +startup-owned catalogue explicitly; it has no all-enabled fallback. It is pure +and performs no artifact read, checker execution, persistence, or routing. + +## Alternatives Rejected + +- Keep compiler maps beside the catalogue: duplicate authority. +- Reuse durable `default_checker_registry`: wrong lifecycle owner. +- Optional catalogue with an enabled fallback: bypasses deployment state. +- Dynamic plugin discovery or project registries: violates the closed v0.1 + contract. + +## Scope Control + +No ZIP parsing, scratch materialization, checker execution, durable evidence, +migration, route, provider I/O, AUTH availability, Submission/admission, +review, contribution, payment, or reputation behavior changed. + +## Product Behavior + +No contributor-facing behavior is activated. Unknown startup configuration +fails closed. Mandatory disabled definitions make the future preparation path +infrastructure-unavailable; advisory disabled definitions remain visible in the +plan. + +## Acceptance Criteria Proof + +- one catalogue owns all pre-submit definition/dispatch metadata; +- all 26 initial definitions are stable, versioned, ordered, bounded, and typed; +- compiler parallel maps and durable-registry dependency are removed; +- plan identity includes project, guide, snapshot, effective policy, pre-submit + policy, catalogue manifest/state, and ordered configuration facts; +- broad token/secret/credential/dependency-directory heuristics are absent from + the generic catalogue; +- no runtime bytes or durable effects occur. + +## Tests And Checks Run + +See `WS-ART-001-04B1-internal-review-evidence.md`. The 21 focused +catalogue/effective-plan tests pass, including exact 26-row identity and +fail-closed policy regressions. Database-backed and repository-wide coverage +remain hosted-CI responsibilities. + +## Test Delta + +One new focused test module; no tests removed, skipped, or weakened. + +## CI Integrity + +Adds `coverage report --include='app/modules/checkers/*' --fail-under=90`. +No threshold, lane, workflow, package script, or existing gate is weakened. + +## Reviewer Results + +All required final tracks passed after valid security, QA, senior-engineering, +product/ops, reuse, test-delta, and documentation findings were repaired. +Architecture and CI-integrity found no required fixes. + +## External Review + +Agent Gates pass on the repaired tree. CodeRabbit completed a substantive +review; its plan-configuration immutability and configuration-documentation +threads were repaired and are resolved. Its incremental follow-up was +rate-limited. The final hosted Backend rerun is in progress. + +## Remaining Risks + +- final hosted database tests and aggregate/per-file coverage must pass; +- CodeRabbit incremental follow-up was rate-limited after both substantive + threads were resolved; +- 04B2 must consume the exact plan without adding another dispatch path. + +## Follow-Up Work + +After human merge, stop. `04B2` begins only under a separate explicit request. + +## Human Review Focus + +- Is the catalogue the only pre-submit authority? +- Can startup-disabled mandatory entries ever be bypassed? +- Does the plan combine locked project policy without executing it? + +## Human Merge Ownership + +- [x] Required final internal reviews pass. +- [ ] Hosted CI and CodeRabbit pass. +- [ ] I can explain what changed and what could break. +- [ ] I explicitly approve this PR for merge. diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index edcec4423..ce0f289a7 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -357,6 +357,10 @@ jobs: working-directory: backend run: coverage report --include='app/modules/audit/*' --precision=2 --fail-under=90 + - name: Pre-submission checker catalogue coverage + working-directory: backend + run: coverage report --include='app/modules/checkers/*' --precision=2 --fail-under=90 + - name: Contribution subsystem coverage working-directory: backend run: coverage report --include='app/modules/contributions/*' --precision=2 --fail-under=90 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index bb3dcb91e..ac6c548e4 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -174,6 +174,7 @@ class Settings(BaseSettings): artifact_submission_zip_maximum_inspection_seconds: float = Field( default=300.0, gt=0.0, le=1800.0 ) + artifact_pre_submission_checker_disabled_ids: str = "" artifact_operation_lock_timeout_seconds: float = Field( default=1800.0, gt=0.0, diff --git a/backend/app/main.py b/backend/app/main.py index ebd873f25..376999dca 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -32,6 +32,10 @@ get_settings, ) from app.interfaces.artifacts import ArtifactStoreBootstrap, ArtifactStoreNamespaceClaim +from app.modules.checkers.catalogue import ( + build_pre_submission_checker_catalogue, + parse_disabled_pre_submission_checker_ids, +) PRODUCTION_LIKE_ENVIRONMENTS = {"staging", "preview", "prod", "production"} MAX_VALIDATION_ERRORS = 20 @@ -64,6 +68,11 @@ async def _application_lifespan(app: FastAPI) -> AsyncIterator[None]: """Reject invalid production authentication configuration before serving.""" settings: Settings = app.state.settings + app.state.pre_submission_checker_catalogue = build_pre_submission_checker_catalogue( + disabled_entry_ids=parse_disabled_pre_submission_checker_ids( + settings.artifact_pre_submission_checker_disabled_ids + ) + ) if settings.pagination_cursor_hmac_secret is None: raise RuntimeError("pagination cursor HMAC secret is required") decode_pagination_cursor_hmac_secret(settings.pagination_cursor_hmac_secret) diff --git a/backend/app/modules/checkers/catalogue.py b/backend/app/modules/checkers/catalogue.py new file mode 100644 index 000000000..e3426ff43 --- /dev/null +++ b/backend/app/modules/checkers/catalogue.py @@ -0,0 +1,649 @@ +"""Single immutable catalogue for pre-submission checker planning.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from enum import StrEnum +from types import MappingProxyType +from typing import Any, Mapping + +from app.core.hashing import canonical_json_hash + + +PRE_SUBMISSION_CATALOGUE_ID = "workstream.pre_submission_checkers" +PRE_SUBMISSION_CATALOGUE_VERSION = "v0.1" +PRE_SUBMISSION_CATALOGUE_SCHEMA_VERSION = "pre_submission_checker_catalogue.v1" +PRE_SUBMISSION_RESULT_SCHEMA_VERSION = "pre_submission_checker_result.v1" + + +class PreSubmissionCatalogueError(ValueError): + """Reject an invalid or ambiguous catalogue configuration.""" + + +class PreSubmissionCheckerClassification(StrEnum): + """Closed operational classification for one catalogue definition.""" + + MANDATORY_SECURITY = "mandatory_security" + MANDATORY_INTEGRITY = "mandatory_integrity" + MANDATORY_ACCOUNTABILITY = "mandatory_accountability" + ADVISORY = "advisory" + + @property + def mandatory(self) -> bool: + """Return whether disabling this definition makes intake unavailable.""" + return self is not PreSubmissionCheckerClassification.ADVISORY + + +class PreSubmissionCheckerPhase(StrEnum): + """Canonical execution-plan phases.""" + + CUSTODY = "custody" + IDENTITY = "identity" + MATERIALIZATION = "materialization" + DEFAULT_POLICY = "default_policy" + PROJECT_POLICY = "project_policy" + + +_PHASE_ORDER = { + phase: index for index, phase in enumerate(PreSubmissionCheckerPhase) +} + + +def pre_submission_phase_order(phase: PreSubmissionCheckerPhase) -> int: + """Return the canonical ordinal for one pre-submission phase.""" + return _PHASE_ORDER[phase] + + +class PreSubmissionCheckerState(StrEnum): + ENABLED = "enabled" + DISABLED = "disabled" + + +class PreSubmissionDisabledBehavior(StrEnum): + INFRASTRUCTURE_UNAVAILABLE = "infrastructure_unavailable" + RECORD_DISABLED_AND_CONTINUE = "record_disabled_and_continue" + + +class PreSubmissionDispatchKind(StrEnum): + PLATFORM_CAPABILITY = "platform_capability" + POLICY_PRIMITIVE = "policy_primitive" + + +class PreSubmissionPlatformCapability(StrEnum): + OUTER_ZIP_VALID = "submission_archive.outer_zip_valid" + PATHS_SAFE = "submission_archive.paths_safe" + ENTRIES_SAFE = "submission_archive.entries_safe" + RESOURCES_BOUNDED = "submission_archive.resources_bounded" + INTEGRITY_VERIFIED = "submission_archive.integrity_verified" + ARCHIVE_IDENTITY = "artifact_commitment.identity" + MANIFEST_IDENTITY = "submission_manifest.semantic_identity" + EXECUTABLE_NORMALIZED = "submission_manifest.executable_normalized" + CONTENT_CHANGED = "submission_change.changed" + SEALED_TREE_VERIFIED = "submission_materialization.sealed_tree_verified" + SUBMISSION_PACKET = "validate_submission_packet" + ATTESTATION = "require_attestation" + SENSITIVE_PATH = "forbid_high_confidence_sensitive_path" + QUALITY_WARNING = "warn_low_quality_generated_artifact" + + +class PreSubmissionPolicyPrimitive(StrEnum): + FORBID_ARTIFACT = "forbid_artifact" + LIMIT_FILE_SIZE = "limit_file_size" + LIMIT_PACKAGE_SIZE = "limit_package_size" + ENFORCE_STORAGE_SCHEME = "enforce_storage_scheme" + VERIFY_HASH = "verify_hash" + REQUIRE_ATTESTATION = "require_attestation" + REQUIRE_PACKAGING = "require_packaging" + REQUIRE_FILE = "require_file" + REQUIRE_MINIMUM_EVIDENCE = "require_minimum_evidence" + REQUIRE_MANIFEST_FIELD = "require_manifest_field" + VALIDATE_SUBMISSION_PACKET = "validate_submission_packet" + WARN_LOW_QUALITY_GENERATED_ARTIFACT = "warn_low_quality_generated_artifact" + + +@dataclass(frozen=True, slots=True) +class PreSubmissionCheckerDefinition: + """One closed typed definition in the process-wide catalogue.""" + + stable_id: str + version: str + public_name: str + owner: str + phase: PreSubmissionCheckerPhase + order: int + dependencies: tuple[str, ...] + classification: PreSubmissionCheckerClassification + typed_inputs: tuple[str, ...] + result_schema: str + failure_code: str + resource_budget: tuple[tuple[str, int], ...] + state: PreSubmissionCheckerState + disabled_behavior: PreSubmissionDisabledBehavior + policy_trace_source: str + dispatch_kind: PreSubmissionDispatchKind + dispatch_capability: str + primitive: str | None = None + policy_fields: tuple[str, ...] = () + + def __post_init__(self) -> None: + values = ( + self.stable_id, + self.version, + self.public_name, + self.owner, + self.result_schema, + self.failure_code, + self.policy_trace_source, + self.dispatch_capability, + ) + if any(type(value) is not str or not value for value in values): + raise PreSubmissionCatalogueError("catalogue definition identity is invalid") + if type(self.order) is not int or self.order < 0: + raise PreSubmissionCatalogueError("catalogue definition order is invalid") + if len(self.dependencies) != len(set(self.dependencies)): + raise PreSubmissionCatalogueError("catalogue definition has duplicate dependencies") + if len(self.typed_inputs) != len(set(self.typed_inputs)) or not self.typed_inputs: + raise PreSubmissionCatalogueError("catalogue definition typed inputs are invalid") + if tuple(sorted(self.resource_budget)) != self.resource_budget or any( + type(key) is not str or not key or type(value) is not int or value < 0 + for key, value in self.resource_budget + ): + raise PreSubmissionCatalogueError("catalogue definition resource budget is invalid") + if self.classification.mandatory: + if ( + self.disabled_behavior + is not PreSubmissionDisabledBehavior.INFRASTRUCTURE_UNAVAILABLE + ): + raise PreSubmissionCatalogueError("mandatory catalogue definition may not skip") + elif ( + self.disabled_behavior is not PreSubmissionDisabledBehavior.RECORD_DISABLED_AND_CONTINUE + ): + raise PreSubmissionCatalogueError("advisory catalogue definition may not block startup") + if self.dispatch_kind is PreSubmissionDispatchKind.POLICY_PRIMITIVE: + if not self.primitive or not self.policy_fields: + raise PreSubmissionCatalogueError("policy catalogue definition is incomplete") + if ( + self.primitive not in set(PreSubmissionPolicyPrimitive) + or self.dispatch_capability != self.primitive + ): + raise PreSubmissionCatalogueError("policy catalogue primitive is unknown") + elif self.primitive is not None or self.policy_fields: + raise PreSubmissionCatalogueError( + "platform capability may not masquerade as a primitive" + ) + elif self.dispatch_capability not in set(PreSubmissionPlatformCapability): + raise PreSubmissionCatalogueError("platform catalogue capability is unknown") + + def manifest_entry(self) -> dict[str, Any]: + """Return the canonical immutable catalogue projection.""" + return { + "stable_id": self.stable_id, + "version": self.version, + "public_name": self.public_name, + "owner": self.owner, + "phase": self.phase.value, + "order": self.order, + "dependencies": list(self.dependencies), + "classification": self.classification.value, + "typed_inputs": list(self.typed_inputs), + "result_schema": self.result_schema, + "failure_code": self.failure_code, + "resource_budget": dict(self.resource_budget), + "state": self.state.value, + "disabled_behavior": self.disabled_behavior.value, + "policy_trace_source": self.policy_trace_source, + "dispatch_kind": self.dispatch_kind.value, + "dispatch_capability": self.dispatch_capability, + "primitive": self.primitive, + "policy_fields": list(self.policy_fields), + } + + +@dataclass(frozen=True, slots=True) +class PreSubmissionCheckerCatalogue: + """Validated process-wide catalogue and its canonical identity.""" + + catalogue_id: str + version: str + schema_version: str + entries: tuple[PreSubmissionCheckerDefinition, ...] + + def __post_init__(self) -> None: + if ( + self.catalogue_id != PRE_SUBMISSION_CATALOGUE_ID + or self.version != PRE_SUBMISSION_CATALOGUE_VERSION + or self.schema_version != PRE_SUBMISSION_CATALOGUE_SCHEMA_VERSION + ): + raise PreSubmissionCatalogueError("catalogue envelope is invalid") + if not self.entries: + raise PreSubmissionCatalogueError("catalogue requires definitions") + stable_ids = [entry.stable_id for entry in self.entries] + if len(stable_ids) != len(set(stable_ids)): + raise PreSubmissionCatalogueError("catalogue contains duplicate stable IDs") + primitives = [entry.primitive for entry in self.entries if entry.primitive is not None] + if len(primitives) != len(set(primitives)): + raise PreSubmissionCatalogueError("catalogue contains duplicate primitives") + expected_order = tuple(sorted(self.entries, key=_definition_sort_key)) + if self.entries != expected_order: + raise PreSubmissionCatalogueError("catalogue definitions are not canonical") + self._validate_dependencies() + + def _validate_dependencies(self) -> None: + by_id = {entry.stable_id: entry for entry in self.entries} + for entry in self.entries: + for dependency_id in entry.dependencies: + dependency = by_id.get(dependency_id) + if dependency is None: + raise PreSubmissionCatalogueError("catalogue dependency is missing") + if _PHASE_ORDER[dependency.phase] > _PHASE_ORDER[entry.phase]: + raise PreSubmissionCatalogueError("catalogue dependency phase is invalid") + if dependency.phase is entry.phase and dependency.order >= entry.order: + raise PreSubmissionCatalogueError("catalogue dependency order is invalid") + visiting: set[str] = set() + visited: set[str] = set() + + def visit(stable_id: str) -> None: + if stable_id in visiting: + raise PreSubmissionCatalogueError("catalogue dependency cycle detected") + if stable_id in visited: + return + visiting.add(stable_id) + for dependency_id in by_id[stable_id].dependencies: + visit(dependency_id) + visiting.remove(stable_id) + visited.add(stable_id) + + for entry in self.entries: + visit(entry.stable_id) + + @property + def manifest(self) -> Mapping[str, Any]: + """Return an immutable top-level manifest projection.""" + return MappingProxyType( + { + "catalogue_id": self.catalogue_id, + "version": self.version, + "schema_version": self.schema_version, + "entries": [entry.manifest_entry() for entry in self.entries], + } + ) + + @property + def manifest_sha256(self) -> str: + return canonical_json_hash(dict(self.manifest)) + + @property + def available(self) -> bool: + return not any( + entry.classification.mandatory and entry.state is PreSubmissionCheckerState.DISABLED + for entry in self.entries + ) + + def definition(self, stable_id: str) -> PreSubmissionCheckerDefinition: + for entry in self.entries: + if entry.stable_id == stable_id: + return entry + raise PreSubmissionCatalogueError("catalogue definition is unknown") + + def primitive_definition(self, primitive: str) -> PreSubmissionCheckerDefinition: + for entry in self.entries: + if entry.primitive == primitive: + return entry + raise PreSubmissionCatalogueError("catalogue primitive is unknown") + + +def build_pre_submission_checker_catalogue( + *, disabled_entry_ids: frozenset[str] = frozenset() +) -> PreSubmissionCheckerCatalogue: + """Build the sole catalogue with startup-fixed availability state.""" + definitions = _default_definitions() + known = {definition.stable_id for definition in definitions} + if not disabled_entry_ids.issubset(known): + raise PreSubmissionCatalogueError("disabled catalogue definition is unknown") + entries = tuple( + replace(definition, state=PreSubmissionCheckerState.DISABLED) + if definition.stable_id in disabled_entry_ids + else definition + for definition in definitions + ) + return PreSubmissionCheckerCatalogue( + PRE_SUBMISSION_CATALOGUE_ID, + PRE_SUBMISSION_CATALOGUE_VERSION, + PRE_SUBMISSION_CATALOGUE_SCHEMA_VERSION, + tuple(sorted(entries, key=_definition_sort_key)), + ) + + +def parse_disabled_pre_submission_checker_ids(raw: str) -> frozenset[str]: + """Parse one startup-owned comma-separated disabled-definition set.""" + if type(raw) is not str: + raise PreSubmissionCatalogueError("disabled catalogue configuration is invalid") + parts = [part.strip() for part in raw.split(",") if part.strip()] + if len(parts) != len(set(parts)): + raise PreSubmissionCatalogueError("disabled catalogue configuration has duplicates") + return frozenset(parts) + + +def _definition_sort_key(entry: PreSubmissionCheckerDefinition) -> tuple[int, int, str]: + return (_PHASE_ORDER[entry.phase], entry.order, entry.stable_id) + + +def _platform( + stable_id: str, + *, + phase: PreSubmissionCheckerPhase, + order: int, + classification: PreSubmissionCheckerClassification, + input_name: str, + capability: str, + failure_code: str, + dependencies: tuple[str, ...] = (), + public_name: str | None = None, +) -> PreSubmissionCheckerDefinition: + return PreSubmissionCheckerDefinition( + stable_id=stable_id, + version="v1", + public_name=public_name or stable_id, + owner="workstream.artifact", + phase=phase, + order=order, + dependencies=dependencies, + classification=classification, + typed_inputs=(input_name,), + result_schema=PRE_SUBMISSION_RESULT_SCHEMA_VERSION, + failure_code=failure_code, + resource_budget=(("maximum_results", 1),), + state=PreSubmissionCheckerState.ENABLED, + disabled_behavior=( + PreSubmissionDisabledBehavior.RECORD_DISABLED_AND_CONTINUE + if classification is PreSubmissionCheckerClassification.ADVISORY + else PreSubmissionDisabledBehavior.INFRASTRUCTURE_UNAVAILABLE + ), + policy_trace_source="workstream_default_policy", + dispatch_kind=PreSubmissionDispatchKind.PLATFORM_CAPABILITY, + dispatch_capability=capability, + ) + + +def _policy( + stable_id: str, + primitive: str, + public_name: str, + policy_fields: tuple[str, ...], + *, + order: int, + classification: PreSubmissionCheckerClassification = PreSubmissionCheckerClassification.MANDATORY_ACCOUNTABILITY, +) -> PreSubmissionCheckerDefinition: + return PreSubmissionCheckerDefinition( + stable_id=stable_id, + version="v1", + public_name=public_name, + owner="workstream.checker_policy", + phase=PreSubmissionCheckerPhase.PROJECT_POLICY, + order=order, + dependencies=("artifact.scratch.sealed_tree_verified",), + classification=classification, + typed_inputs=("LockedProjectCheckerRule", "SubmissionManifestView"), + result_schema=PRE_SUBMISSION_RESULT_SCHEMA_VERSION, + failure_code="pre_submission_checker_failed", + resource_budget=(("maximum_results", 1),), + state=PreSubmissionCheckerState.ENABLED, + disabled_behavior=( + PreSubmissionDisabledBehavior.RECORD_DISABLED_AND_CONTINUE + if classification is PreSubmissionCheckerClassification.ADVISORY + else PreSubmissionDisabledBehavior.INFRASTRUCTURE_UNAVAILABLE + ), + policy_trace_source="locked_effective_project_submission_artifact_policy", + dispatch_kind=PreSubmissionDispatchKind.POLICY_PRIMITIVE, + dispatch_capability=primitive, + primitive=primitive, + policy_fields=tuple(sorted(policy_fields)), + ) + + +def _default_definitions() -> tuple[PreSubmissionCheckerDefinition, ...]: + security = PreSubmissionCheckerClassification.MANDATORY_SECURITY + integrity = PreSubmissionCheckerClassification.MANDATORY_INTEGRITY + accountability = PreSubmissionCheckerClassification.MANDATORY_ACCOUNTABILITY + advisory = PreSubmissionCheckerClassification.ADVISORY + custody = PreSubmissionCheckerPhase.CUSTODY + identity = PreSubmissionCheckerPhase.IDENTITY + materialization = PreSubmissionCheckerPhase.MATERIALIZATION + defaults = PreSubmissionCheckerPhase.DEFAULT_POLICY + definitions = ( + _platform( + "artifact.outer_zip.valid", + phase=custody, + order=10, + classification=security, + input_name="SubmissionArchiveInspectionResult", + capability="submission_archive.outer_zip_valid", + failure_code="submission_archive_malformed", + ), + _platform( + "artifact.archive.paths_safe", + phase=custody, + order=20, + classification=security, + input_name="SubmissionArchiveInspectionResult", + capability="submission_archive.paths_safe", + failure_code="submission_archive_unsafe_entry", + dependencies=("artifact.outer_zip.valid",), + ), + _platform( + "artifact.archive.entries_safe", + phase=custody, + order=30, + classification=security, + input_name="SubmissionArchiveInspectionResult", + capability="submission_archive.entries_safe", + failure_code="submission_archive_unsafe_entry", + dependencies=("artifact.archive.paths_safe",), + ), + _platform( + "artifact.archive.resources_bounded", + phase=custody, + order=40, + classification=security, + input_name="SubmissionArchiveInspectionResult", + capability="submission_archive.resources_bounded", + failure_code="submission_archive_limit_exceeded", + dependencies=("artifact.archive.entries_safe",), + ), + _platform( + "artifact.archive.integrity_verified", + phase=custody, + order=50, + classification=integrity, + input_name="SubmissionArchiveInspectionResult", + capability="submission_archive.integrity_verified", + failure_code="submission_archive_integrity_failure", + dependencies=("artifact.archive.resources_bounded",), + ), + _platform( + "artifact.archive.identity_computed", + phase=identity, + order=10, + classification=integrity, + input_name="ArtifactCommitment", + capability="artifact_commitment.identity", + failure_code="submission_archive_identity_unavailable", + dependencies=("artifact.archive.integrity_verified",), + ), + _platform( + "artifact.manifest.semantic_identity_computed", + phase=identity, + order=20, + classification=integrity, + input_name="SubmissionManifest", + capability="submission_manifest.semantic_identity", + failure_code="submission_manifest_identity_unavailable", + dependencies=("artifact.archive.identity_computed",), + ), + _platform( + "artifact.manifest.executable_normalized", + phase=identity, + order=30, + classification=integrity, + input_name="SubmissionManifest", + capability="submission_manifest.executable_normalized", + failure_code="submission_manifest_executable_invalid", + dependencies=("artifact.manifest.semantic_identity_computed",), + ), + _platform( + "artifact.revision.content_changed", + phase=identity, + order=40, + classification=integrity, + input_name="SubmissionChangeGateResult", + capability="submission_change.changed", + failure_code="submission_manifest_unchanged", + dependencies=("artifact.manifest.executable_normalized",), + ), + _platform( + "artifact.scratch.sealed_tree_verified", + phase=materialization, + order=10, + classification=integrity, + input_name="SealedSubmissionTree", + capability="submission_materialization.sealed_tree_verified", + failure_code="pre_submission_materialization_unavailable", + dependencies=("artifact.revision.content_changed",), + ), + _platform( + "submission.packet.required_fields", + phase=defaults, + order=10, + classification=accountability, + input_name="SubmissionPacket", + capability="validate_submission_packet", + failure_code="pre_submission_packet_invalid", + dependencies=("artifact.scratch.sealed_tree_verified",), + public_name="check_submission_packet", + ), + _platform( + "submission.attestation.required_topics", + phase=defaults, + order=20, + classification=accountability, + input_name="SubmissionPacket", + capability="require_attestation", + failure_code="pre_submission_attestation_missing", + dependencies=("submission.packet.required_fields",), + public_name="check_confidentiality_attestation", + ), + _platform( + "artifact.sensitive_paths.high_confidence", + phase=defaults, + order=30, + classification=security, + input_name="SubmissionManifest", + capability="forbid_high_confidence_sensitive_path", + failure_code="pre_submission_sensitive_path_forbidden", + dependencies=("submission.attestation.required_topics",), + public_name="check_forbidden_files", + ), + _platform( + "artifact.quality.placeholder_signal", + phase=defaults, + order=40, + classification=advisory, + input_name="SubmissionPacket", + capability="warn_low_quality_generated_artifact", + failure_code="pre_submission_quality_warning", + dependencies=("artifact.sensitive_paths.high_confidence",), + public_name="check_low_quality_generated_artifacts", + ), + _policy( + "policy.submission_packet.validate", + "validate_submission_packet", + "check_submission_packet", + ("required_packet_fields",), + order=10, + ), + _policy( + "policy.storage_scheme.enforce", + "enforce_storage_scheme", + "check_evidence_integrity", + ("allowed_storage_schemes",), + order=20, + classification=integrity, + ), + _policy( + "policy.manifest_field.require", + "require_manifest_field", + "check_evidence_integrity", + ("manifest_required",), + order=30, + classification=integrity, + ), + _policy( + "policy.hash.verify", + "verify_hash", + "check_evidence_integrity", + ("artifact_hash_algorithm", "artifact_hash_required"), + order=40, + classification=integrity, + ), + _policy( + "policy.file.require", + "require_file", + "check_required_files", + ("required_artifacts",), + order=50, + ), + _policy( + "policy.evidence.minimum", + "require_minimum_evidence", + "check_evidence_present", + ("required_evidence",), + order=60, + ), + _policy( + "policy.artifact.forbid", + "forbid_artifact", + "check_forbidden_files", + ("forbidden_artifacts",), + order=70, + classification=security, + ), + _policy( + "policy.attestation.require", + "require_attestation", + "check_confidentiality_attestation", + ("attestation_terms",), + order=80, + ), + _policy( + "policy.file_size.limit", + "limit_file_size", + "check_evidence_integrity", + ("maximum_file_size_bytes",), + order=90, + classification=integrity, + ), + _policy( + "policy.package_size.limit", + "limit_package_size", + "check_evidence_integrity", + ("maximum_package_size_bytes",), + order=100, + classification=integrity, + ), + _policy( + "policy.packaging.require", + "require_packaging", + "check_submission_packet", + ("packaging",), + order=110, + ), + _policy( + "policy.generated_quality.warn", + "warn_low_quality_generated_artifact", + "check_low_quality_generated_artifacts", + ("workstream_default_policy",), + order=120, + classification=advisory, + ), + ) + return tuple(sorted(definitions, key=_definition_sort_key)) diff --git a/backend/app/modules/checkers/compiler.py b/backend/app/modules/checkers/compiler.py index bf830233b..04a31072a 100644 --- a/backend/app/modules/checkers/compiler.py +++ b/backend/app/modules/checkers/compiler.py @@ -3,64 +3,24 @@ from __future__ import annotations from dataclasses import dataclass +from functools import lru_cache from typing import Any from app.core.hashing import canonical_json_hash -from app.modules.checkers.runner import UnknownChecker, default_checker_registry +from app.modules.checkers.catalogue import ( + PreSubmissionCatalogueError, + PreSubmissionCheckerCatalogue, + build_pre_submission_checker_catalogue, +) PRE_SUBMIT_COMPILER_VERSION = "workstream-pre-submit-compiler-v0.1" PRE_SUBMIT_BUNDLE_SCHEMA_VERSION = "pre_submit_checker_bundle.v1" PRE_SUBMIT_SPEC_SCHEMA_VERSION = "pre_submit_checker_spec.v1" PRIMITIVES_VERSION = "workstream-pre-submit-primitives.v1" -APPROVED_PRIMITIVES = { - "forbid_artifact", - "limit_file_size", - "limit_package_size", - "enforce_storage_scheme", - "verify_hash", - "require_attestation", - "require_packaging", - "require_file", - "require_minimum_evidence", - "require_manifest_field", - "validate_submission_packet", - "warn_low_quality_generated_artifact", -} - BLOCKING_SEVERITY = "blocking" WARNING_SEVERITY = "warning" -PRIMITIVE_CHECKER_NAME_MAP = { - "forbid_artifact": "check_forbidden_files", - "limit_file_size": "check_submission_packet", - "limit_package_size": "check_submission_packet", - "enforce_storage_scheme": "check_submission_packet", - "verify_hash": "check_evidence_integrity", - "require_attestation": "check_confidentiality_attestation", - "require_packaging": "check_submission_packet", - "require_file": "check_required_files", - "require_minimum_evidence": "check_evidence_present", - "require_manifest_field": "check_submission_packet", - "validate_submission_packet": "check_submission_packet", - "warn_low_quality_generated_artifact": "check_low_quality_generated_artifacts", -} - -PRIMITIVE_POLICY_FIELDS = { - "forbid_artifact": {"forbidden_artifacts"}, - "limit_file_size": {"maximum_file_size_bytes"}, - "limit_package_size": {"maximum_package_size_bytes"}, - "enforce_storage_scheme": {"allowed_storage_schemes"}, - "verify_hash": {"artifact_hash_required", "artifact_hash_algorithm"}, - "require_attestation": {"attestation_terms"}, - "require_packaging": {"packaging"}, - "require_file": {"required_artifacts"}, - "require_minimum_evidence": {"required_evidence"}, - "require_manifest_field": {"manifest_required"}, - "validate_submission_packet": {"required_packet_fields"}, - "warn_low_quality_generated_artifact": {"workstream_default_policy"}, -} - class PreSubmitCheckerCompilerError(ValueError): """Raised when checker specification compilation fails closed.""" @@ -77,6 +37,12 @@ class CompiledPreSubmitCheckerPolicy: checker_configs: dict[str, Any] +@lru_cache(maxsize=1) +def _compiler_catalogue() -> PreSubmissionCheckerCatalogue: + """Return the immutable base catalogue used for policy compilation.""" + return build_pre_submission_checker_catalogue() + + def build_project_pre_submit_checker_spec( effective_policy: dict[str, Any], effective_policy_hash: str, @@ -217,12 +183,6 @@ def compile_project_pre_submit_checker_spec( "rules": rules, } checker_names = _checker_names_for_rules(rules) - try: - default_checker_registry().require_registered(set(checker_names)) - except UnknownChecker as exc: - raise PreSubmitCheckerCompilerError( - "compiled checker bundle references unknown checkers" - ) from exc return CompiledPreSubmitCheckerPolicy( compiler_version=compiler_version, compiled_bundle=bundle, @@ -275,12 +235,6 @@ def validate_compiled_pre_submit_checker_bundle( raise PreSubmitCheckerCompilerError("compiled checker bundle rules are not canonical") _validate_rule_coverage(effective_policy, canonical_rules) checker_names = _checker_names_for_rules(canonical_rules) - try: - default_checker_registry().require_registered(set(checker_names)) - except UnknownChecker as exc: - raise PreSubmitCheckerCompilerError( - "compiled checker bundle references unknown checkers" - ) from exc return checker_names @@ -316,18 +270,32 @@ def _validate_spec_shape(spec: dict[str, Any], effective_policy_hash: str) -> No def _canonical_rule(rule: dict[str, Any]) -> dict[str, Any]: """Validate and normalize one checker-spec rule.""" primitive = rule.get("primitive") - if primitive not in APPROVED_PRIMITIVES: + if not isinstance(primitive, str): raise PreSubmitCheckerCompilerError("checker spec contains unknown primitive") + try: + definition = _compiler_catalogue().primitive_definition(primitive) + except PreSubmissionCatalogueError as exc: + raise PreSubmitCheckerCompilerError("checker spec contains unknown primitive") from exc severity = rule.get("severity") if severity not in {BLOCKING_SEVERITY, WARNING_SEVERITY}: raise PreSubmitCheckerCompilerError("checker spec contains invalid severity") + expected_severity = ( + BLOCKING_SEVERITY if definition.classification.mandatory else WARNING_SEVERITY + ) + if severity != expected_severity: + message = ( + "checker spec weakens severity" + if definition.classification.mandatory + else "checker spec escalates warning-only rule" + ) + raise PreSubmitCheckerCompilerError(message) policy_fields = rule.get("policy_fields") if not isinstance(policy_fields, list) or not policy_fields: raise PreSubmitCheckerCompilerError("checker spec rule lacks policy trace fields") if not all(isinstance(field, str) and field for field in policy_fields): raise PreSubmitCheckerCompilerError("checker spec rule contains invalid policy trace field") actual_policy_fields = set(policy_fields) - expected_policy_fields = PRIMITIVE_POLICY_FIELDS[primitive] + expected_policy_fields = set(definition.policy_fields) if actual_policy_fields != expected_policy_fields: raise PreSubmitCheckerCompilerError( f"checker spec rule has untraceable policy fields for {primitive}" @@ -580,9 +548,15 @@ def _policy_object_list(effective_policy: dict[str, Any], field: str) -> list[di def _checker_names_for_rules(rules: list[dict[str, Any]]) -> list[str]: """Build stable checker-name projections from compiled primitive rules.""" + catalogue = _compiler_catalogue() names: list[str] = [] for rule in rules: - name = PRIMITIVE_CHECKER_NAME_MAP[rule["primitive"]] + try: + name = catalogue.primitive_definition(rule["primitive"]).public_name + except PreSubmissionCatalogueError as exc: + raise PreSubmitCheckerCompilerError( + "compiled checker bundle references unknown catalogue definitions" + ) from exc if name not in names: names.append(name) return names diff --git a/backend/app/modules/checkers/effective_plan.py b/backend/app/modules/checkers/effective_plan.py new file mode 100644 index 000000000..4082c76ac --- /dev/null +++ b/backend/app/modules/checkers/effective_plan.py @@ -0,0 +1,370 @@ +"""Pure compiler for one locked effective pre-submission execution plan.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +from app.core.hashing import canonical_json_hash +from app.modules.checkers.catalogue import ( + PreSubmissionCheckerCatalogue, + PreSubmissionCheckerDefinition, + PreSubmissionCheckerPhase, + PreSubmissionCheckerState, + pre_submission_phase_order, +) +from app.modules.checkers.compiler import ( + PRE_SUBMIT_BUNDLE_SCHEMA_VERSION, + PRE_SUBMIT_COMPILER_VERSION, + PRIMITIVES_VERSION, + PreSubmitCheckerCompilerError, + validate_compiled_pre_submit_checker_bundle, +) + + +EFFECTIVE_PRE_SUBMISSION_PLAN_SCHEMA_VERSION = "effective_pre_submission_plan.v1" +EFFECTIVE_PRE_SUBMISSION_PLAN_HASH_DOMAIN = "workstream.effective_pre_submission_plan.v1" + + +class EffectivePreSubmissionPlanError(ValueError): + """Reject a stale, ambiguous, or unavailable effective plan.""" + + +class PreSubmissionInfrastructureUnavailableError(EffectivePreSubmissionPlanError): + """Fail preparation closed when mandatory deployment capability is disabled.""" + + +@dataclass(frozen=True, slots=True) +class EffectivePreSubmissionPlanLineage: + """Exact server-locked lineage for one future preparation request.""" + + project_id: UUID + guide_id: UUID + guide_version: int + source_snapshot_id: UUID + source_snapshot_hash: str + effective_policy_id: UUID + effective_policy_hash: str + pre_submit_policy_id: UUID + pre_submit_policy_bundle_hash: str + + def __post_init__(self) -> None: + if any( + type(value) is not UUID + for value in ( + self.project_id, + self.guide_id, + self.source_snapshot_id, + self.effective_policy_id, + self.pre_submit_policy_id, + ) + ): + raise EffectivePreSubmissionPlanError("effective plan lineage id is invalid") + if type(self.guide_version) is not int or self.guide_version <= 0: + raise EffectivePreSubmissionPlanError("effective plan guide version is invalid") + for value in ( + self.source_snapshot_hash, + self.effective_policy_hash, + self.pre_submit_policy_bundle_hash, + ): + _validate_sha256(value) + + def as_dict(self) -> dict[str, Any]: + return { + "project_id": str(self.project_id), + "guide_id": str(self.guide_id), + "guide_version": self.guide_version, + "source_snapshot_id": str(self.source_snapshot_id), + "source_snapshot_hash": self.source_snapshot_hash, + "effective_policy_id": str(self.effective_policy_id), + "effective_policy_hash": self.effective_policy_hash, + "pre_submit_policy_id": str(self.pre_submit_policy_id), + "pre_submit_policy_bundle_hash": self.pre_submit_policy_bundle_hash, + } + + +@dataclass(frozen=True, slots=True) +class EffectivePreSubmissionPlanEntry: + """One ordered definition or deterministic locked-policy rule instance.""" + + definition_id: str + definition_version: str + public_name: str + phase: str + order: int + dependencies: tuple[str, ...] + classification: str + state: str + disabled_behavior: str + dispatch_kind: str + dispatch_capability: str + typed_inputs: tuple[str, ...] + result_schema: str + failure_code: str + resource_budget: tuple[tuple[str, int], ...] + policy_trace_source: str + rule_instance_id: str | None + configuration: FrozenJsonObject + configuration_sha256: str + + def as_dict(self) -> dict[str, Any]: + return { + "definition_id": self.definition_id, + "definition_version": self.definition_version, + "public_name": self.public_name, + "phase": self.phase, + "order": self.order, + "dependencies": list(self.dependencies), + "classification": self.classification, + "state": self.state, + "disabled_behavior": self.disabled_behavior, + "dispatch_kind": self.dispatch_kind, + "dispatch_capability": self.dispatch_capability, + "typed_inputs": list(self.typed_inputs), + "result_schema": self.result_schema, + "failure_code": self.failure_code, + "resource_budget": dict(self.resource_budget), + "policy_trace_source": self.policy_trace_source, + "rule_instance_id": self.rule_instance_id, + "configuration": self.configuration.as_dict(), + "configuration_sha256": self.configuration_sha256, + } + + +@dataclass(frozen=True, slots=True) +class EffectivePreSubmissionExecutionPlan: + """Canonical side-effect-free plan consumed by later execution chunks.""" + + lineage: EffectivePreSubmissionPlanLineage + catalogue_id: str + catalogue_version: str + catalogue_schema_version: str + catalogue_manifest_sha256: str + entries: tuple[EffectivePreSubmissionPlanEntry, ...] + plan_sha256: str + + def as_dict(self) -> dict[str, Any]: + return _plan_body( + self.lineage, + self.catalogue_id, + self.catalogue_version, + self.catalogue_schema_version, + self.catalogue_manifest_sha256, + self.entries, + ) + + +@dataclass(frozen=True, slots=True) +class FrozenJsonObject: + """Hashable deeply immutable projection of one canonical JSON object.""" + + items: tuple[tuple[str, Any], ...] + + def as_dict(self) -> dict[str, Any]: + """Return a detached mutable JSON projection for serialization.""" + return {key: _thaw_json(value) for key, value in self.items} + + +def compile_effective_pre_submission_execution_plan( + *, + lineage: EffectivePreSubmissionPlanLineage, + effective_policy: dict[str, Any], + compiled_bundle: dict[str, Any], + catalogue: PreSubmissionCheckerCatalogue, +) -> EffectivePreSubmissionExecutionPlan: + """Compose platform definitions and locked project rules without execution.""" + if not catalogue.available: + raise PreSubmissionInfrastructureUnavailableError( + "pre_submission_infrastructure_unavailable" + ) + if canonical_json_hash(compiled_bundle) != lineage.pre_submit_policy_bundle_hash: + raise EffectivePreSubmissionPlanError("compiled checker bundle hash mismatch") + if ( + compiled_bundle.get("schema_version") != PRE_SUBMIT_BUNDLE_SCHEMA_VERSION + or compiled_bundle.get("compiler_version") != PRE_SUBMIT_COMPILER_VERSION + or compiled_bundle.get("primitives_version") != PRIMITIVES_VERSION + ): + raise EffectivePreSubmissionPlanError("compiled checker bundle envelope is invalid") + if compiled_bundle.get("effective_policy_hash") != lineage.effective_policy_hash: + raise EffectivePreSubmissionPlanError("compiled checker effective policy mismatch") + if canonical_json_hash(effective_policy) != lineage.effective_policy_hash: + raise EffectivePreSubmissionPlanError( + "locked effective project submission artifact policy hash mismatch" + ) + try: + validate_compiled_pre_submit_checker_bundle( + effective_policy, + lineage.effective_policy_hash, + compiled_bundle, + compiler_version=PRE_SUBMIT_COMPILER_VERSION, + ) + except PreSubmitCheckerCompilerError as exc: + raise EffectivePreSubmissionPlanError( + f"compiled checker bundle does not enforce the locked effective policy: {exc}" + ) from exc + rules = compiled_bundle.get("rules") + if not isinstance(rules, list) or not rules: + raise EffectivePreSubmissionPlanError("compiled checker bundle rules are invalid") + + entries = [ + _entry_from_definition(definition, configuration={}, rule_instance_id=None) + for definition in catalogue.entries + if definition.primitive is None + ] + seen_primitives: set[str] = set() + catalogue_manifest_sha256 = catalogue.manifest_sha256 + for rule in rules: + if not isinstance(rule, dict): + raise EffectivePreSubmissionPlanError("compiled checker rule is invalid") + primitive = rule.get("primitive") + if not isinstance(primitive, str) or primitive in seen_primitives: + raise EffectivePreSubmissionPlanError("compiled checker primitive is invalid") + seen_primitives.add(primitive) + try: + definition = catalogue.primitive_definition(primitive) + except ValueError as exc: + raise EffectivePreSubmissionPlanError("compiled checker primitive is unknown") from exc + if definition.state is PreSubmissionCheckerState.DISABLED: + if definition.classification.mandatory: + raise PreSubmissionInfrastructureUnavailableError( + "pre_submission_infrastructure_unavailable" + ) + configuration = rule.get("config") + if not isinstance(configuration, dict): + raise EffectivePreSubmissionPlanError("compiled checker configuration is invalid") + if rule.get("policy_fields") != list(definition.policy_fields): + raise EffectivePreSubmissionPlanError("compiled checker policy trace is invalid") + expected_severity = "warning" if not definition.classification.mandatory else "blocking" + if rule.get("severity") != expected_severity: + raise EffectivePreSubmissionPlanError("compiled checker severity is invalid") + rule_instance_id = canonical_json_hash( + { + "domain": "workstream.pre_submission_rule_instance.v1", + "catalogue_id": catalogue.catalogue_id, + "catalogue_version": catalogue.version, + "catalogue_manifest_sha256": catalogue_manifest_sha256, + "definition_id": definition.stable_id, + "definition_version": definition.version, + "effective_policy_id": str(lineage.effective_policy_id), + "effective_policy_hash": lineage.effective_policy_hash, + "pre_submit_policy_id": str(lineage.pre_submit_policy_id), + "configuration": configuration, + } + ) + entries.append( + _entry_from_definition( + definition, + configuration=configuration, + rule_instance_id=rule_instance_id, + ) + ) + ordered_entries = tuple( + sorted( + entries, key=lambda entry: (_phase_order(entry.phase), entry.order, entry.definition_id) + ) + ) + body = _plan_body( + lineage, + catalogue.catalogue_id, + catalogue.version, + catalogue.schema_version, + catalogue_manifest_sha256, + ordered_entries, + ) + return EffectivePreSubmissionExecutionPlan( + lineage=lineage, + catalogue_id=catalogue.catalogue_id, + catalogue_version=catalogue.version, + catalogue_schema_version=catalogue.schema_version, + catalogue_manifest_sha256=catalogue_manifest_sha256, + entries=ordered_entries, + plan_sha256=canonical_json_hash(body), + ) + + +def _entry_from_definition( + definition: PreSubmissionCheckerDefinition, + *, + configuration: dict[str, Any], + rule_instance_id: str | None, +) -> EffectivePreSubmissionPlanEntry: + configuration_sha256 = canonical_json_hash(configuration) + frozen_configuration = FrozenJsonObject( + tuple((key, _freeze_json(value)) for key, value in sorted(configuration.items())) + ) + return EffectivePreSubmissionPlanEntry( + definition_id=definition.stable_id, + definition_version=definition.version, + public_name=definition.public_name, + phase=definition.phase.value, + order=definition.order, + dependencies=definition.dependencies, + classification=definition.classification.value, + state=definition.state.value, + disabled_behavior=definition.disabled_behavior.value, + dispatch_kind=definition.dispatch_kind.value, + dispatch_capability=definition.dispatch_capability, + typed_inputs=definition.typed_inputs, + result_schema=definition.result_schema, + failure_code=definition.failure_code, + resource_budget=definition.resource_budget, + policy_trace_source=definition.policy_trace_source, + rule_instance_id=rule_instance_id, + configuration=frozen_configuration, + configuration_sha256=configuration_sha256, + ) + + +def _plan_body( + lineage: EffectivePreSubmissionPlanLineage, + catalogue_id: str, + catalogue_version: str, + catalogue_schema_version: str, + catalogue_manifest_sha256: str, + entries: tuple[EffectivePreSubmissionPlanEntry, ...], +) -> dict[str, Any]: + return { + "domain": EFFECTIVE_PRE_SUBMISSION_PLAN_HASH_DOMAIN, + "schema_version": EFFECTIVE_PRE_SUBMISSION_PLAN_SCHEMA_VERSION, + "lineage": lineage.as_dict(), + "catalogue": { + "id": catalogue_id, + "version": catalogue_version, + "schema_version": catalogue_schema_version, + "manifest_sha256": catalogue_manifest_sha256, + }, + "entries": [entry.as_dict() for entry in entries], + } + + +def _phase_order(phase: str) -> int: + return pre_submission_phase_order(PreSubmissionCheckerPhase(phase)) + + +def _freeze_json(value: Any) -> Any: + if isinstance(value, dict): + return FrozenJsonObject( + tuple((key, _freeze_json(item)) for key, item in sorted(value.items())) + ) + if isinstance(value, list): + return tuple(_freeze_json(item) for item in value) + return value + + +def _thaw_json(value: Any) -> Any: + if isinstance(value, FrozenJsonObject): + return value.as_dict() + if isinstance(value, tuple): + return [_thaw_json(item) for item in value] + return value + + +def _validate_sha256(value: str) -> None: + if ( + type(value) is not str + or len(value) != 71 + or not value.startswith("sha256:") + or any(character not in "0123456789abcdef" for character in value[7:]) + ): + raise EffectivePreSubmissionPlanError("effective plan lineage hash is invalid") diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py index d7bc72861..0993cc7cb 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -158,6 +158,7 @@ class TestLane: TestLane( "task_lifecycle", ( + "tests/test_checker_catalogue.py", "tests/test_checkers.py", "tests/test_review_queue_persistence.py", "tests/test_tasks.py", diff --git a/backend/tests/test_checker_catalogue.py b/backend/tests/test_checker_catalogue.py new file mode 100644 index 000000000..fed04ce8c --- /dev/null +++ b/backend/tests/test_checker_catalogue.py @@ -0,0 +1,498 @@ +"""Proof for the single pre-submission catalogue and effective plan compiler.""" + +from __future__ import annotations + +from dataclasses import replace +import json +from uuid import uuid4 + +import pytest + +from app.core.hashing import canonical_json_hash +from app.core.config import Settings +from app.main import create_app +from app.modules.checkers.catalogue import ( + PRE_SUBMISSION_CATALOGUE_ID, + PRE_SUBMISSION_CATALOGUE_SCHEMA_VERSION, + PreSubmissionCatalogueError, + PreSubmissionCheckerCatalogue, + PreSubmissionDisabledBehavior, + PreSubmissionCheckerPhase, + PreSubmissionCheckerState, + build_pre_submission_checker_catalogue, + parse_disabled_pre_submission_checker_ids, +) +from app.modules.checkers.compiler import ( + compile_effective_project_submission_artifact_policy, +) +from app.modules.checkers.effective_plan import ( + EffectivePreSubmissionPlanError, + EffectivePreSubmissionPlanLineage, + PreSubmissionInfrastructureUnavailableError, + compile_effective_pre_submission_execution_plan, +) + + +def _effective_policy() -> dict[str, object]: + default_policy = { + "required_packet_fields": ["summary", "worker_attestation"], + "forbidden_artifacts": [{"pattern": ".env"}, {"pattern": ".git/**"}], + "attestation_terms": ["rights_confirmed"], + } + return { + "workstream_default_policy": default_policy, + "project_policy": {}, + "required_packet_fields": default_policy["required_packet_fields"], + "required_artifacts": [{"key": "task.toml", "required": True}], + "required_evidence": [{"key": "results", "required": True}], + "forbidden_artifacts": default_policy["forbidden_artifacts"], + "attestation_terms": default_policy["attestation_terms"], + "manifest_required": True, + "artifact_hash_required": True, + "artifact_hash_algorithm": "sha256", + "allowed_storage_schemes": ["s3"], + "maximum_file_size_bytes": 1_000_000, + "maximum_package_size_bytes": 5_000_000, + "packaging": {"package_required": True, "allowed_package_formats": ["zip"]}, + } + + +def _compiled_and_lineage() -> tuple[dict[str, object], EffectivePreSubmissionPlanLineage]: + effective_policy_hash = canonical_json_hash(_effective_policy()) + compiled = compile_effective_project_submission_artifact_policy( + _effective_policy(), effective_policy_hash + ) + lineage = EffectivePreSubmissionPlanLineage( + project_id=uuid4(), + guide_id=uuid4(), + guide_version=3, + source_snapshot_id=uuid4(), + source_snapshot_hash="sha256:" + "2" * 64, + effective_policy_id=uuid4(), + effective_policy_hash=effective_policy_hash, + pre_submit_policy_id=uuid4(), + pre_submit_policy_bundle_hash=compiled.compiled_bundle_hash, + ) + return compiled.compiled_bundle, lineage + + +def test_catalogue_is_single_canonical_closed_namespace() -> None: + catalogue = build_pre_submission_checker_catalogue() + ids = [entry.stable_id for entry in catalogue.entries] + + assert catalogue.catalogue_id == PRE_SUBMISSION_CATALOGUE_ID + assert catalogue.schema_version == PRE_SUBMISSION_CATALOGUE_SCHEMA_VERSION + assert len(ids) == len(set(ids)) == 26 + assert ids == [ + entry.stable_id + for entry in sorted( + catalogue.entries, + key=lambda entry: ( + list(PreSubmissionCheckerPhase).index(entry.phase), + entry.order, + entry.stable_id, + ), + ) + ] + assert catalogue.manifest_sha256 == canonical_json_hash(dict(catalogue.manifest)) + + expected = { + "enforce_storage_scheme": ("policy.storage_scheme.enforce", "check_evidence_integrity"), + "require_manifest_field": ("policy.manifest_field.require", "check_evidence_integrity"), + "limit_file_size": ("policy.file_size.limit", "check_evidence_integrity"), + "limit_package_size": ("policy.package_size.limit", "check_evidence_integrity"), + } + for primitive, (stable_id, public_name) in expected.items(): + definition = catalogue.primitive_definition(primitive) + assert (definition.stable_id, definition.public_name) == (stable_id, public_name) + + +def test_catalogue_exact_v01_contract_is_locked() -> None: + catalogue = build_pre_submission_checker_catalogue() + actual = { + "|".join( + ( + entry.stable_id, + entry.version, + entry.public_name, + entry.classification.value, + entry.phase.value, + str(entry.order), + entry.dispatch_kind.value, + entry.dispatch_capability, + entry.primitive or "-", + entry.disabled_behavior.value, + ",".join(f"{name}={value}" for name, value in entry.resource_budget), + ) + ) + for entry in catalogue.entries + } + expected = { + "artifact.outer_zip.valid|v1|artifact.outer_zip.valid|mandatory_security|custody|10|platform_capability|submission_archive.outer_zip_valid|-|infrastructure_unavailable|maximum_results=1", + "artifact.archive.paths_safe|v1|artifact.archive.paths_safe|mandatory_security|custody|20|platform_capability|submission_archive.paths_safe|-|infrastructure_unavailable|maximum_results=1", + "artifact.archive.entries_safe|v1|artifact.archive.entries_safe|mandatory_security|custody|30|platform_capability|submission_archive.entries_safe|-|infrastructure_unavailable|maximum_results=1", + "artifact.archive.resources_bounded|v1|artifact.archive.resources_bounded|mandatory_security|custody|40|platform_capability|submission_archive.resources_bounded|-|infrastructure_unavailable|maximum_results=1", + "artifact.archive.integrity_verified|v1|artifact.archive.integrity_verified|mandatory_integrity|custody|50|platform_capability|submission_archive.integrity_verified|-|infrastructure_unavailable|maximum_results=1", + "artifact.archive.identity_computed|v1|artifact.archive.identity_computed|mandatory_integrity|identity|10|platform_capability|artifact_commitment.identity|-|infrastructure_unavailable|maximum_results=1", + "artifact.manifest.semantic_identity_computed|v1|artifact.manifest.semantic_identity_computed|mandatory_integrity|identity|20|platform_capability|submission_manifest.semantic_identity|-|infrastructure_unavailable|maximum_results=1", + "artifact.manifest.executable_normalized|v1|artifact.manifest.executable_normalized|mandatory_integrity|identity|30|platform_capability|submission_manifest.executable_normalized|-|infrastructure_unavailable|maximum_results=1", + "artifact.revision.content_changed|v1|artifact.revision.content_changed|mandatory_integrity|identity|40|platform_capability|submission_change.changed|-|infrastructure_unavailable|maximum_results=1", + "artifact.scratch.sealed_tree_verified|v1|artifact.scratch.sealed_tree_verified|mandatory_integrity|materialization|10|platform_capability|submission_materialization.sealed_tree_verified|-|infrastructure_unavailable|maximum_results=1", + "submission.packet.required_fields|v1|check_submission_packet|mandatory_accountability|default_policy|10|platform_capability|validate_submission_packet|-|infrastructure_unavailable|maximum_results=1", + "submission.attestation.required_topics|v1|check_confidentiality_attestation|mandatory_accountability|default_policy|20|platform_capability|require_attestation|-|infrastructure_unavailable|maximum_results=1", + "artifact.sensitive_paths.high_confidence|v1|check_forbidden_files|mandatory_security|default_policy|30|platform_capability|forbid_high_confidence_sensitive_path|-|infrastructure_unavailable|maximum_results=1", + "artifact.quality.placeholder_signal|v1|check_low_quality_generated_artifacts|advisory|default_policy|40|platform_capability|warn_low_quality_generated_artifact|-|record_disabled_and_continue|maximum_results=1", + "policy.submission_packet.validate|v1|check_submission_packet|mandatory_accountability|project_policy|10|policy_primitive|validate_submission_packet|validate_submission_packet|infrastructure_unavailable|maximum_results=1", + "policy.storage_scheme.enforce|v1|check_evidence_integrity|mandatory_integrity|project_policy|20|policy_primitive|enforce_storage_scheme|enforce_storage_scheme|infrastructure_unavailable|maximum_results=1", + "policy.manifest_field.require|v1|check_evidence_integrity|mandatory_integrity|project_policy|30|policy_primitive|require_manifest_field|require_manifest_field|infrastructure_unavailable|maximum_results=1", + "policy.hash.verify|v1|check_evidence_integrity|mandatory_integrity|project_policy|40|policy_primitive|verify_hash|verify_hash|infrastructure_unavailable|maximum_results=1", + "policy.file.require|v1|check_required_files|mandatory_accountability|project_policy|50|policy_primitive|require_file|require_file|infrastructure_unavailable|maximum_results=1", + "policy.evidence.minimum|v1|check_evidence_present|mandatory_accountability|project_policy|60|policy_primitive|require_minimum_evidence|require_minimum_evidence|infrastructure_unavailable|maximum_results=1", + "policy.artifact.forbid|v1|check_forbidden_files|mandatory_security|project_policy|70|policy_primitive|forbid_artifact|forbid_artifact|infrastructure_unavailable|maximum_results=1", + "policy.attestation.require|v1|check_confidentiality_attestation|mandatory_accountability|project_policy|80|policy_primitive|require_attestation|require_attestation|infrastructure_unavailable|maximum_results=1", + "policy.file_size.limit|v1|check_evidence_integrity|mandatory_integrity|project_policy|90|policy_primitive|limit_file_size|limit_file_size|infrastructure_unavailable|maximum_results=1", + "policy.package_size.limit|v1|check_evidence_integrity|mandatory_integrity|project_policy|100|policy_primitive|limit_package_size|limit_package_size|infrastructure_unavailable|maximum_results=1", + "policy.packaging.require|v1|check_submission_packet|mandatory_accountability|project_policy|110|policy_primitive|require_packaging|require_packaging|infrastructure_unavailable|maximum_results=1", + "policy.generated_quality.warn|v1|check_low_quality_generated_artifacts|advisory|project_policy|120|policy_primitive|warn_low_quality_generated_artifact|warn_low_quality_generated_artifact|record_disabled_and_continue|maximum_results=1", + } + + assert actual == expected + + +def test_catalogue_manifest_does_not_install_broad_generic_path_heuristics() -> None: + manifest = json.dumps(dict(build_pre_submission_checker_catalogue().manifest), sort_keys=True) + for forbidden_heuristic in ("token*", "secret*", "credential*", "node_modules"): + assert forbidden_heuristic not in manifest + + +def test_catalogue_rejects_unknown_duplicate_and_unsafe_dependency_configuration() -> None: + with pytest.raises(PreSubmissionCatalogueError, match="unknown"): + build_pre_submission_checker_catalogue(disabled_entry_ids=frozenset({"unknown"})) + with pytest.raises(PreSubmissionCatalogueError, match="duplicates"): + parse_disabled_pre_submission_checker_ids( + "artifact.outer_zip.valid,artifact.outer_zip.valid" + ) + + valid = build_pre_submission_checker_catalogue() + first = valid.entries[0] + duplicate = tuple( + sorted( + (*valid.entries, first), key=lambda entry: (entry.phase, entry.order, entry.stable_id) + ) + ) + with pytest.raises(PreSubmissionCatalogueError, match="duplicate stable IDs"): + PreSubmissionCheckerCatalogue( + valid.catalogue_id, valid.version, valid.schema_version, duplicate + ) + + version_alias = tuple( + sorted( + (*valid.entries, replace(first, version="v2")), + key=lambda entry: (entry.phase, entry.order, entry.stable_id), + ) + ) + with pytest.raises(PreSubmissionCatalogueError, match="duplicate stable IDs"): + PreSubmissionCheckerCatalogue( + valid.catalogue_id, valid.version, valid.schema_version, version_alias + ) + + broken = (replace(first, dependencies=("missing.definition",)), *valid.entries[1:]) + with pytest.raises(PreSubmissionCatalogueError, match="dependency is missing"): + PreSubmissionCheckerCatalogue( + valid.catalogue_id, valid.version, valid.schema_version, broken + ) + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ({"stable_id": ""}, "identity"), + ({"order": -1}, "order"), + ( + {"dependencies": ("artifact.outer_zip.valid", "artifact.outer_zip.valid")}, + "duplicate dependencies", + ), + ({"typed_inputs": ()}, "typed inputs"), + ({"resource_budget": (("maximum_results", -1),)}, "resource budget"), + ( + {"disabled_behavior": PreSubmissionDisabledBehavior.RECORD_DISABLED_AND_CONTINUE}, + "mandatory catalogue definition may not skip", + ), + ({"dispatch_capability": "unknown.capability"}, "capability is unknown"), + ], +) +def test_catalogue_definition_validation_fails_closed( + mutation: dict[str, object], message: str +) -> None: + definition = build_pre_submission_checker_catalogue().entries[0] + with pytest.raises(PreSubmissionCatalogueError, match=message): + replace(definition, **mutation) + + +def test_catalogue_rejects_noncanonical_order_and_dependency_order() -> None: + catalogue = build_pre_submission_checker_catalogue() + with pytest.raises(PreSubmissionCatalogueError, match="not canonical"): + PreSubmissionCheckerCatalogue( + catalogue.catalogue_id, + catalogue.version, + catalogue.schema_version, + tuple(reversed(catalogue.entries)), + ) + first, second, *rest = catalogue.entries + broken_first = replace(first, dependencies=(second.stable_id,)) + broken = tuple( + sorted( + (broken_first, second, *rest), + key=lambda entry: ( + list(PreSubmissionCheckerPhase).index(entry.phase), + entry.order, + entry.stable_id, + ), + ) + ) + with pytest.raises(PreSubmissionCatalogueError, match="dependency order"): + PreSubmissionCheckerCatalogue( + catalogue.catalogue_id, + catalogue.version, + catalogue.schema_version, + broken, + ) + + +def test_mandatory_disabled_fails_closed_and_advisory_disabled_stays_visible() -> None: + compiled_bundle, lineage = _compiled_and_lineage() + mandatory_disabled = build_pre_submission_checker_catalogue( + disabled_entry_ids=frozenset({"artifact.outer_zip.valid"}) + ) + assert mandatory_disabled.available is False + with pytest.raises( + PreSubmissionInfrastructureUnavailableError, + match="pre_submission_infrastructure_unavailable", + ): + compile_effective_pre_submission_execution_plan( + lineage=lineage, + effective_policy=_effective_policy(), + compiled_bundle=compiled_bundle, + catalogue=mandatory_disabled, + ) + + advisory_disabled = build_pre_submission_checker_catalogue( + disabled_entry_ids=frozenset({"artifact.quality.placeholder_signal"}) + ) + plan = compile_effective_pre_submission_execution_plan( + lineage=lineage, + effective_policy=_effective_policy(), + compiled_bundle=compiled_bundle, + catalogue=advisory_disabled, + ) + entry = next( + item for item in plan.entries if item.definition_id == "artifact.quality.placeholder_signal" + ) + assert entry.state == PreSubmissionCheckerState.DISABLED.value + assert entry.disabled_behavior == "record_disabled_and_continue" + + +def test_effective_plan_is_deterministic_and_commits_to_lineage_catalogue_and_config() -> None: + compiled_bundle, lineage = _compiled_and_lineage() + first = compile_effective_pre_submission_execution_plan( + lineage=lineage, + effective_policy=_effective_policy(), + compiled_bundle=compiled_bundle, + catalogue=build_pre_submission_checker_catalogue(), + ) + second = compile_effective_pre_submission_execution_plan( + lineage=lineage, + effective_policy=_effective_policy(), + compiled_bundle=compiled_bundle, + catalogue=build_pre_submission_checker_catalogue(), + ) + assert first == second + assert first.plan_sha256 == canonical_json_hash(first.as_dict()) + assert ( + first.catalogue_manifest_sha256 == build_pre_submission_checker_catalogue().manifest_sha256 + ) + assert {entry.definition_id for entry in first.entries}.issuperset( + { + "artifact.outer_zip.valid", + "artifact.manifest.semantic_identity_computed", + "submission.packet.required_fields", + "policy.file.require", + } + ) + assert all(entry.configuration_sha256 for entry in first.entries) + required_file = next( + entry for entry in first.entries if entry.definition_id == "policy.file.require" + ) + exported = required_file.as_dict() + exported["configuration"]["artifact_keys"].append("mutated-after-hash") + assert required_file.as_dict()["configuration"]["artifact_keys"] == ["task.toml"] + assert hash(required_file) + assert first.plan_sha256 == canonical_json_hash(first.as_dict()) + + changed_lineage = replace(lineage, project_id=uuid4()) + changed = compile_effective_pre_submission_execution_plan( + lineage=changed_lineage, + effective_policy=_effective_policy(), + compiled_bundle=compiled_bundle, + catalogue=build_pre_submission_checker_catalogue(), + ) + assert changed.plan_sha256 != first.plan_sha256 + + advisory_disabled = build_pre_submission_checker_catalogue( + disabled_entry_ids=frozenset({"artifact.quality.placeholder_signal"}) + ) + state_changed = compile_effective_pre_submission_execution_plan( + lineage=lineage, + effective_policy=_effective_policy(), + compiled_bundle=compiled_bundle, + catalogue=advisory_disabled, + ) + assert state_changed.plan_sha256 != first.plan_sha256 + first_rule_ids = { + entry.definition_id: entry.rule_instance_id + for entry in first.entries + if entry.rule_instance_id is not None + } + state_changed_rule_ids = { + entry.definition_id: entry.rule_instance_id + for entry in state_changed.entries + if entry.rule_instance_id is not None + } + assert state_changed_rule_ids.keys() == first_rule_ids.keys() + assert all( + state_changed_rule_ids[definition_id] != rule_instance_id + for definition_id, rule_instance_id in first_rule_ids.items() + ) + + +def test_effective_plan_rejects_stale_or_non_catalogue_bundle_facts() -> None: + compiled_bundle, lineage = _compiled_and_lineage() + with pytest.raises(EffectivePreSubmissionPlanError, match="hash mismatch"): + compile_effective_pre_submission_execution_plan( + lineage=replace( + lineage, + pre_submit_policy_bundle_hash="sha256:" + "9" * 64, + ), + effective_policy=_effective_policy(), + compiled_bundle=compiled_bundle, + catalogue=build_pre_submission_checker_catalogue(), + ) + + altered = {**compiled_bundle, "rules": [dict(rule) for rule in compiled_bundle["rules"]]} + altered["rules"][0]["primitive"] = "legacy_alias" + altered_lineage = replace( + lineage, + pre_submit_policy_bundle_hash=canonical_json_hash(altered), + ) + with pytest.raises(EffectivePreSubmissionPlanError, match="unknown"): + compile_effective_pre_submission_execution_plan( + lineage=altered_lineage, + effective_policy=_effective_policy(), + compiled_bundle=altered, + catalogue=build_pre_submission_checker_catalogue(), + ) + + stale_envelope = {**compiled_bundle, "compiler_version": "obsolete"} + with pytest.raises(EffectivePreSubmissionPlanError, match="envelope"): + compile_effective_pre_submission_execution_plan( + lineage=replace( + lineage, + pre_submit_policy_bundle_hash=canonical_json_hash(stale_envelope), + ), + effective_policy=_effective_policy(), + compiled_bundle=stale_envelope, + catalogue=build_pre_submission_checker_catalogue(), + ) + + +def test_effective_plan_rejects_bundle_that_omits_locked_required_rule() -> None: + compiled_bundle, lineage = _compiled_and_lineage() + altered = { + **compiled_bundle, + "rules": [ + dict(rule) + for rule in compiled_bundle["rules"] + if rule["primitive"] != "require_file" + ], + } + altered_lineage = replace( + lineage, + pre_submit_policy_bundle_hash=canonical_json_hash(altered), + ) + + with pytest.raises(EffectivePreSubmissionPlanError, match="locked effective policy"): + compile_effective_pre_submission_execution_plan( + lineage=altered_lineage, + effective_policy=_effective_policy(), + compiled_bundle=altered, + catalogue=build_pre_submission_checker_catalogue(), + ) + + +def test_effective_plan_rejects_policy_body_that_does_not_match_locked_hash() -> None: + compiled_bundle, lineage = _compiled_and_lineage() + weakened_policy = { + **_effective_policy(), + "required_artifacts": [], + } + + with pytest.raises(EffectivePreSubmissionPlanError, match="policy hash mismatch"): + compile_effective_pre_submission_execution_plan( + lineage=lineage, + effective_policy=weakened_policy, + compiled_bundle=compiled_bundle, + catalogue=build_pre_submission_checker_catalogue(), + ) + + +def test_effective_plan_lineage_rejects_ambiguous_identity_version_and_hash() -> None: + _, lineage = _compiled_and_lineage() + with pytest.raises(EffectivePreSubmissionPlanError, match="lineage id"): + replace(lineage, project_id="not-a-uuid") + with pytest.raises(EffectivePreSubmissionPlanError, match="guide version"): + replace(lineage, guide_version=0) + with pytest.raises(EffectivePreSubmissionPlanError, match="lineage hash"): + replace(lineage, source_snapshot_hash="sha256:bad") + + +def test_compiler_uses_catalogue_without_durable_checker_registry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from app.modules.checkers import runner + + monkeypatch.setattr( + runner, + "default_checker_registry", + lambda: (_ for _ in ()).throw(AssertionError("durable registry used")), + ) + compiled = compile_effective_project_submission_artifact_policy( + _effective_policy(), "sha256:" + "3" * 64 + ) + assert "check_evidence_integrity" in compiled.checker_names + assert "check_submission_packet" in compiled.checker_names + + +async def test_application_startup_installs_fixed_catalogue_configuration() -> None: + app = create_app( + Settings( + environment="test", + artifact_pre_submission_checker_disabled_ids="artifact.quality.placeholder_signal", + ) + ) + async with app.router.lifespan_context(app): + catalogue = app.state.pre_submission_checker_catalogue + assert catalogue.definition("artifact.quality.placeholder_signal").state is ( + PreSubmissionCheckerState.DISABLED + ) + + +async def test_application_startup_rejects_unknown_catalogue_configuration() -> None: + app = create_app( + Settings( + environment="test", + artifact_pre_submission_checker_disabled_ids="unknown.definition", + ) + ) + with pytest.raises(PreSubmissionCatalogueError, match="unknown"): + async with app.router.lifespan_context(app): + pass diff --git a/backend/tests/test_ci_test_lanes.py b/backend/tests/test_ci_test_lanes.py index 3c62b1db9..ca63bb6b8 100644 --- a/backend/tests/test_ci_test_lanes.py +++ b/backend/tests/test_ci_test_lanes.py @@ -42,6 +42,7 @@ def test_measured_hotspots_have_explicit_semantic_owners() -> None: assert modules_by_lane["project_lifecycle"] == {"tests/test_projects.py"} assert modules_by_lane["task_lifecycle"] == { + "tests/test_checker_catalogue.py", "tests/test_checkers.py", "tests/test_review_queue_persistence.py", "tests/test_tasks.py", diff --git a/docs/architecture_checker_framework.md b/docs/architecture_checker_framework.md index 67610222f..3af7fee01 100644 --- a/docs/architecture_checker_framework.md +++ b/docs/architecture_checker_framework.md @@ -15,7 +15,7 @@ Every checker returns: "definition": { "dispatch_authority": "pre_submission_catalogue", "definition_id": "policy.submission_packet.validate", - "definition_version": 1, + "definition_version": "v1", "public_name": "check_submission_packet", "source": "locked_project_policy" }, @@ -36,8 +36,10 @@ Every checker returns: `definition` and `policy_trace` are typed provenance, not arbitrary metadata. The discriminating `dispatch_authority` gives `definition_id/version` exact -meaning: for `pre_submission_catalogue` they are the catalogue ID/version; for -`durable_checker_registry` they are the registered durable checker ID/version. +meaning: for `pre_submission_catalogue` they are the stable catalogue +definition ID/version, while the effective plan separately binds the top-level +catalogue ID/version and manifest hash; for `durable_checker_registry` they are +the registered durable checker ID/version. For Workstream defaults, `source=workstream_default` and policy-only fields may be null under the closed schema. Serialization preserves this exact nesting. Persistence uses explicit authority-neutral columns or schema-validated typed @@ -69,6 +71,15 @@ implementations may be exposed through typed adapters, but neither the durable registry nor the pre-submission catalogue may duplicate IDs, primitive maps, or dispatch authority. +The hidden catalogue implementation is process-wide and immutable. Deployment +configuration may name disabled stable definition IDs only at startup. Unknown +or duplicate IDs, invalid dependencies/order, unknown capabilities, and unsafe +disabled behavior fail startup validation. The pure effective-plan compiler +binds project, guide version, source snapshot, effective policy, pre-submit +policy, catalogue manifest, availability state, ordered definition/configuration +hashes, and deterministic rule-instance identities. It does not read artifacts +or invoke either pre-submit or durable checkers. + Definition fields: - `checker_id` diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index a93367239..cd5aa30bf 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -1473,8 +1473,11 @@ Fields: - `checker_run_id` - `checker_name` - `dispatch_authority` -- `definition_id` (catalogue ID for pre-submit; registry checker ID for durable) -- `definition_version` (catalogue or registry version selected by authority) +- `definition_id` (stable catalogue definition ID for pre-submit; registry + checker ID for durable) +- `definition_version` (catalogue definition or registry checker version + selected by authority; the effective plan separately binds the top-level + catalogue ID/version and manifest hash) - `result_source` - `effective_plan_hash` - `rule_instance_id` (nullable only for non-policy/default definitions) diff --git a/docs/spec_artifact_storage_service.md b/docs/spec_artifact_storage_service.md index 754eb76e4..1c5428266 100644 --- a/docs/spec_artifact_storage_service.md +++ b/docs/spec_artifact_storage_service.md @@ -874,6 +874,13 @@ approved operational change. | `WORKSTREAM_ARTIFACT_SUBMISSION_ZIP_MAXIMUM_COMPRESSION_RATIO` | `100` | `10000` | Maximum expanded-to-compressed ratio for one file. | | `WORKSTREAM_ARTIFACT_SUBMISSION_ZIP_MAXIMUM_INSPECTION_SECONDS` | `300` | `1800` | Complete synchronous inspector deadline inside the preparation deadline. | +Pre-submission checker catalogue configuration is separate from ZIP inspection +bounds: + +| Environment variable | Default | Format | Contract | +|---|---:|---|---| +| `WORKSTREAM_ARTIFACT_PRE_SUBMISSION_CHECKER_DISABLED_IDS` | empty | closed catalogue IDs | Startup-fixed comma-separated stable IDs. Unknown or duplicate IDs fail startup. Any disabled mandatory definition makes preparation infrastructure-unavailable; disabled advisory definitions remain visible in the effective plan and do not block remaining execution. | + Enabled artifact storage also requires an explicit durable-byte policy. None of these limits has a default, and startup fails unless all four are positive: @@ -1098,12 +1105,18 @@ using the scratch root. Cleanup under an exclusive generation/lease may restore only the manager-owned directory write bit after no-follow ownership/type validation, then remove the tree; archive-supplied modes are never applied. -Mandatory platform and locked Project Guide checks consume that same read-only -scratch tree as one ordered `EffectivePreSubmissionExecutionPlan` assembled from -the central versioned `PreSubmissionCheckerCatalogue`: +Mandatory platform and locked Project Guide checks form one ordered +`EffectivePreSubmissionExecutionPlan` assembled from the central versioned +`PreSubmissionCheckerCatalogue`. Custody and identity phases consume the typed, +verified results produced by the earlier archive/manifest capabilities; +materialization and policy phases may consume the same sealed read-only scratch +tree plus those typed facts. They do not reopen or independently reinterpret +the uploaded ZIP: ```text -artifact custody/safety phase +artifact custody/safety result phase +-> artifact and semantic-manifest identity result phase +-> sealed materialization phase -> Workstream default policy phase -> locked Project Guide policy phase -> one bounded result/evidence envelope