diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c5a592..787b9fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,8 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 - name: Install locked uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: diff --git a/AGENTS.md b/AGENTS.md index a546f65..d9d83f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,9 @@ python orchestrator.py --init --project "새_프로젝트_폴더" # List configured projects python orchestrator.py --list-projects +# Read-only all-project structure audit +python orchestrator.py --audit-structure + # Smoke tests python -m unittest tests.test_smoke diff --git a/CHANGELOG.md b/CHANGELOG.md index 98798e3..059ccc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,19 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and changed-file `uv run ruff check ...`, and `uv run python figops_mcp_server.py --smoke`. - Maintainers tag releases after merge; implementers open PRs but do not merge or tag. +## [0.20.1] - 2026-07-25 + +Post-0.20.0 release-candidate metadata entry. This records the next patch +candidate only; merge, tag, and package publication remain maintainer actions. + +### Changed + +- Record the secure production MCP host approval launcher as the release-bound + operational path for production host approval evidence. +- Tighten canonical contract wording and release-gate cleanup so the + release-candidate state stays explicit without changing the published-release + record. + ## [0.20.0] - 2026-07-15 Source release-candidate entry. The latest published PyPI package and GitHub diff --git a/docs/QA.md b/docs/QA.md index 817eab7..5aecb77 100644 --- a/docs/QA.md +++ b/docs/QA.md @@ -64,6 +64,31 @@ python orchestrator.py --docker --docker-build --project "12. ionoelastomer" --s - Docker 경로에서도 lock gate, provenance, plot 출력이 동일하게 통과해야 함. - uv/R 런타임 상태와 자격증명은 repo 안이 아니라 외부 runtime/cache 경로에 있어야 함. +### 1.1 전체 프로젝트 구조 진단 (Read-only) + +프로젝트 파일을 변경하거나 파이프라인을 실행하지 않고, 연구 루트에서 +발견되는 프로젝트의 선언 구조를 진단한다. + +```bash +python orchestrator.py --audit-structure +python orchestrator.py --audit-structure --audit-structure-format json --scan-depth 2 +``` + +- 기본 출력은 Markdown이며, `--audit-structure-format json`은 JSON을 + stdout으로 출력한다. 시도 provenance는 stderr로 남는다. +- `--scan-depth`는 discovery 깊이를 제한한다. +- 이 모드는 독립 모드다. `--project`, `--check-all`, `--list-projects`와 + 파이프라인/변경 옵션을 함께 주면 fail-fast 오류가 난다. +- 합격 조건: exit code = 0, 진단 보고서가 stdout에 존재하고, analysis/ + plot/diagram 실행이나 project 파일 변경이 없다. `--audit-structure-format` + 단독 사용은 오류여야 한다. + +이 출력은 구조 검토용 진단 보고서이며 runtime manifest, durable result, +또는 evidence receipt가 아니다. 따라서 `results/` 아래에 연구 산출물로 +복사하거나 promotion 대상으로 취급하지 않는다. 실행 로그·cache·snapshot· +상세 manifest는 외부 runtime root에, 의미 있는 결과와 receipt는 선언된 +project role root에 각각 보관한다. + --- ## 2) Regression & Integrity 기준안 @@ -292,5 +317,9 @@ explicit path, with `if-no-files-found: error`, so missing evidence fails closed - **허브 모듈 수정 시**: `hub_core/` 내부 로직 변경 시 반드시 2개 이상의 서로 다른 프로젝트(`ionoelastomer`, `Sulfur_polymer`)에 대해 테스트를 수행. - **Runtime 상태 분리**: 데이터 결과값, 회귀 baseline, 실행 로그, 자격증명은 repo 밖 runtime/cache 경로에 둔다. DVC/data registry는 현재 운영 표면에서 retired 상태다. +- **구조 진단 출력 분리**: `--audit-structure`의 stdout 보고서는 일회성 + diagnostic surface다. runtime 산출물이나 durable 연구 결과로 승격하지 + 않으며, 보존이 필요하면 별도 운영 기록으로 보관하고 프로젝트 `results/` + 역할 트리와 혼합하지 않는다. -**Last Update**: 2026-07-04 (journal visual evidence gate guidance) +**Last Update**: 2026-07-23 (all-project structure audit CLI and journal visual evidence guidance) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f7c408b..c834f5c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -97,7 +97,17 @@ Current release-candidate checkpoint: inventory/audit/plan/role-binding/apply, launcher-authorized external-raw execution, native no-replace result promotion, durable receipts, measured policy evidence, and verified project-script claims including conservative - dynamic-annotation discovery. CI run + dynamic-annotation discovery. The conservative dependency-scanner facade + `dependency_script_inspection.analyze_dependency_script` now delegates + bounded language-specific extraction to `dependency_python_inspection.py` and + `dependency_r_inspection.py`, with shared path predicates and deterministic + result ordering in `dependency_scan_common.py`. It reports deterministic + Python/R static candidates, unresolved hard-coded/dynamic references, and + incomplete scans without guessing roles; scanner output is evidence only, + while unresolved or incomplete findings are plan blockers, not approvals. + `role_roots` resolves a literal only through its most-specific declared + terminal semantic root; grouping roots `scripts` and `results` never clear + blockers, and equal-depth terminal ties remain unresolved. CI run [`29689087108`](https://github.com/Moonweave-Research/figops/actions/runs/29689087108) passed for source head `9e4d340b718529bd0f65ba46b2124dda718918a2`: macOS full pytest was 2,322 passed, 22 skipped, and 104 subtests, including the native @@ -118,6 +128,55 @@ Current release-candidate checkpoint: reference: [PR #224 owner authorization](https://github.com/Moonweave-Research/figops/pull/224#issuecomment-5016360221). Execute merge, tag, package publication, GitHub Release, and release promotion only after rechecking technical gates for the exact release commit. +- The CLI now has an independent all-project structure diagnostic: + `python orchestrator.py --audit-structure` (or + `python orchestrator.py --audit-structure --audit-structure-format json`). + It consumes the read-only structure + inventory/audit path for projects discovered under the research root, honors + `--scan-depth`, and emits the rendered report on stdout without running a + pipeline or changing project files. The diagnostic report is review output + only; it retains invalid/boundary-blocked project rows for review, keeps + `proposed_changes` empty, and is not a runtime manifest, durable result, or + evidence receipt. +- Structure migration follows the finding-to-plan selection matrix in + `docs/project-structure-contract.md`: `invalid`, `boundary_blocked`, + `skipped`, `audit_error`, and ambiguous/heuristic unknowns remain report-only + (unknowns and proposed mappings are candidate-only). Only explicit reviewed + `approved_mappings` and typed config edits form a copy-only plan. A dry-run + returns the deterministic `plan_digest` and bound + `FIGOPS-APPLY-` token; apply requires the identical reviewed + inputs and token. The token proves plan integrity and exact replay, not + independent human identity or attestation; the compatibility workflow does + not close self-approval. The Phase 6 host-rooted approval authority contract + is defined in the canonical + [`runtime-integrity SSOT`](specs/2026-07-15-project-structure-runtime-integrity-plan.md#phase-6-host-rooted-approval-authority-contract) + and requires a canonical payload, host capability/signature trust proof, + currentness/revocation checks, and fail-closed apply ordering. Approval + is enforced when secure MCP mode sets `require_host_approval: true` and is + backed by the host-owned process-local `ApprovalAuthorityRoot`; the host + receipt is rechecked at the mutation boundary. Default compatibility mode + remains token-only for backward compatibility, so audit/plan control + evidence, LLM JSON, and copy/runtime/durable/evidence receipts are not + approval. The production `graphhub_mcp_server.py`/`figops_mcp_server.py` + launcher is the trusted injection boundary: it creates or receives the + host-owned process-local root and enables secure mode. An embedded host may + inject an optional host-owned root through the constructor-only + `host_authority_root` channel together with `require_host_approval: true`; if + the secure flag/root are omitted, the embedded constructor preserves + compatibility/token-only behavior. Compatibility constructors and the + historical `GraphHubMCPServer` class remain token-only and are not Phase 6 or + release evidence. The Phase 6 + host-approval gate is satisfied for the production launcher; full release + still requires the remaining exact-commit gates. Audit/plan control evidence + never becomes a runtime manifest or durable result, and runtime remains + external to the project. + +- Structure normalization applies a fail-closed guard: a plan containing any + `hardcoded_unresolved_references` or `unresolved_proposals` is rejected before + copy, even with a valid digest and confirmation token. Parse/read/unsupported + language failures and dynamic dependency expressions set the scanner's + incomplete signal; no filename, extension, or directory heuristic can assign + a semantic role. --- @@ -154,10 +213,15 @@ hub_core/ project_structure_contract.py # v1.1 role/DAG/alias resolution legacy_structure_resolver.py # legacy 1.0 in-memory compatibility view project_layout.py # shared scaffold/normalization inventory - structure_inventory.py / structure_audit.py / structure_plan.py - # read-only semantic discovery and reviewed plan + structure_inventory.py / structure_audit.py / structure_audit_report.py + # read-only semantic discovery and all-project report + structure_plan.py # deterministic reviewed copy plan structure_role_binding.py # approved destinations bound to declared roots structure_apply.py # write-gated copy-only apply transaction + dependency_script_inspection.py # dependency scanner facade and evidence API + dependency_python_inspection.py # bounded Python dependency extraction helper + dependency_r_inspection.py # bounded R dependency extraction helper + dependency_scan_common.py # shared path predicates and result ordering runtime_boundary.py # project/result/runtime disjointness atomic_no_clobber.py # native consuming same-FS no-replace publication durable_promotion.py # staged same-filesystem result promotion @@ -411,6 +475,12 @@ and scale/profile resolution into `themes.font_token_resolver`. The public passed through explicitly. With `themes.journal_theme` now below 800 lines, no tracked Python module exceeds the current architecture split signal. +The dependency scanner split keeps `hub_core.dependency_script_inspection` as +the compatibility facade while moving bounded Python and R extraction into +`hub_core.dependency_python_inspection` and +`hub_core.dependency_r_inspection`; shared path predicates and deterministic +evidence ordering live in `hub_core.dependency_scan_common`. + The current execution plan for that maintenance track lives in `docs/specs/2026-06-28-large-module-decomposition-plan.md`. diff --git a/docs/architecture.md b/docs/architecture.md index ba8f536..b8267e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,8 +1,9 @@ # FigOps - Architecture -> Companion to `docs/ROADMAP.md`. Describes the current v0.20.0 release-candidate -> architecture after the v0.19.0 release, including the AI-native v2 agent -> surface and the PR #224 declared-project/runtime-result integrity path. +> Companion to `docs/ROADMAP.md`. Describes the v0.20.0 published architecture +> baseline plus current Phase 2 follow-up/Draft PR context, including the +> AI-native v2 agent surface and the PR #224 declared-project/runtime-result +> integrity path. ## Layers and dependency direction @@ -10,6 +11,7 @@ Dependencies point **downward only**. A layer may import from layers below it, never above. ``` +orchestrator.py # CLI pipeline coordinator and read-only audit mode figops_mcp_server.py # entrypoint (stdio); --smoke; thin | v @@ -39,10 +41,15 @@ hub_core/legacy_structure_resolver.py # schema-less 1.0 -> in-memory 1.1 view hub_core/project_layout.py # one scaffold/normalization layout inventory hub_core/structure_inventory.py # read-only semantic inventory hub_core/structure_audit.py # findings, graph, unresolved classification +hub_core/structure_audit_report.py # all-project diagnostic report assembly/rendering hub_core/structure_plan.py # deterministic reviewed copy plan hub_core/structure_role_binding.py # destination -> declared role-root binding hub_core/structure_stage_cleanup.py # ownership-safe private-stage/lease cleanup hub_core/structure_apply.py # token/CAS-guarded copy-only transaction +hub_core/dependency_script_inspection.py # dependency scanner facade and evidence API +hub_core/dependency_python_inspection.py # bounded Python dependency extraction helper +hub_core/dependency_r_inspection.py # bounded R dependency extraction helper +hub_core/dependency_scan_common.py # shared path predicates and result ordering hub_core/runtime_boundary.py # project/result/runtime disjointness hub_core/atomic_no_clobber.py # native consuming no-replace namespace move hub_core/durable_promotion.py # destination-filesystem staged promotion @@ -88,7 +95,7 @@ policy-only; there is no import-linter contract in `.github/workflows/ci.yml` as of v0.20.0. Remaining over-budget files should be handled as scoped maintenance tracks rather than broad rewrites. -Current files over the approximate 800-line budget, measured on 2026-07-16 with +Current files over the approximate 800-line budget, measured on 2026-07-23 with the architecture inventory helper: ```bash @@ -101,14 +108,19 @@ python hub_uv.py run python scripts/architecture_inventory.py --format markdown No Python module in the tracked architecture roots (`hub_core`, `plotting`, and -`themes`) currently exceeds the 800-line split signal. Overlay -normalization now lives in `plotting/renderers/annotation_normalization.py`, +`themes`) currently exceeds the 800-line split signal. Render-project +workflow/policy integrity decisions now live in +`hub_core/mcp/render_project_integrity_context.py`, while the project-render MCP +tool preserves its compatibility imports. Overlay normalization now lives in `plotting/renderers/annotation_normalization.py`, while the public overlay façade and compatibility imports remain stable. Structure-plan destination binding now lives in `hub_core/structure_role_binding.py`, while private-stage and directory-lease cleanup lives in `hub_core/structure_stage_cleanup.py`; `hub_core/structure_apply.py` retains its private compatibility aliases while focusing on transactional execution and config compare-and-swap. +Workflow-intent config defaults, validation, and inspectable report assembly now +live in `hub_core/config_workflow_intent.py`, while `hub_core/config_parser.py` +keeps the public compatibility imports. The 2026-06-29 decomposition wave reduced the previous primary hotspots below 1000 lines while preserving compatibility shims: @@ -188,8 +200,9 @@ Windows safety smoke; source vector bytes are never substituted for a preview. The AI-native façade split remains intact after the structure work. Shared tool schema primitives live in `hub_core/mcp/tool_schema_common.py`, and the v1.1 project-structure tool schema lives in `hub_core/mcp/structure_schemas.py`, -while the registry façade continues to feed validation, discovery, and -generated references. Overlay normalization remains in +while Phase 2 project-render policy and workflow response schemas live in +`hub_core/mcp/phase2_render_schemas.py`. The registry façade continues to feed +validation, discovery, and generated references. Overlay normalization remains in `plotting/renderers/annotation_normalization.py`, with compatibility exports in the public overlay façade. @@ -204,6 +217,22 @@ every approved destination back to its declared semantic root, while `structure_stage_cleanup.py` owns transaction-private stage and lease cleanup. Reviewed mutation is isolated in `structure_apply.py` and remains copy-only. +`dependency_script_inspection.py` is the public read-only facade for bounded +Python/R dependency evidence. It delegates language-specific extraction to +`dependency_python_inspection.py` and `dependency_r_inspection.py`, while +`dependency_scan_common.py` owns shared path predicates and deterministic +deduplication/order. Its `analyze_dependency_script(...)` API returns +deterministic static candidates plus `hardcoded_unresolved_references` and a +`dependency_scan_incomplete` signal. It never executes scripts or guesses a +semantic role: a caller-provided `role_roots` mapping resolves a literal only +through its most-specific declared terminal semantic root. Grouping roots +`scripts` and `results` never clear blockers, and equal-depth terminal matches +remain unresolved. Parse/read/unsupported-language failures and dynamic path +expressions remain incomplete evidence and block a migration plan. +`structure_apply.py` also rejects non-empty `unresolved_proposals` before any +copy, so a valid digest/token cannot turn scanner evidence or an unresolved +proposal into an apply approval. + Runtime and durable-result mechanics are separate from structure discovery. `runtime_boundary.py` enforces project/result/runtime disjointness, `atomic_no_clobber.py` provides the only publication primitive: Windows @@ -307,6 +336,93 @@ should use `FigOpsMCPServer(surface_profile="v2" | "compatibility")` or the `GRAPH_HUB_MCP_SURFACE_PROFILE` launcher environment setting. Profile-aware references can be rendered from the live registry without duplicating alias schemas. +The production `graphhub_mcp_server.py` launcher (and its +`figops_mcp_server.py` entrypoint) is the trusted approval injection boundary: +it supplies a host-owned process-local `ApprovalAuthorityRoot` and enables +`require_host_approval=True`. Tool arguments, project files, environment +variables, plans, and receipts cannot supply or replace that root. An embedded +host may opt into secure mode by passing its own root through the constructor-only +`host_authority_root` argument together with `require_host_approval=True`. If the +secure flag/root are omitted, the embedded constructor preserves +compatibility/token-only behavior. `GraphHubMCPServer` and other compatibility +constructors are token-only compatibility surfaces and are not Phase 6 or release +evidence. + +## All-project structure audit (CLI) + +The CLI exposes an independent, read-only structure diagnostic for the whole +discovery root: + +```bash +python orchestrator.py --audit-structure +python orchestrator.py --audit-structure --audit-structure-format json --scan-depth 2 +``` + +`orchestrator.py` resolves the research root, records the attempt as +`selector_kind: audit_structure`, and delegates to +`hub_core.structure_audit_report.build_structure_audit_report(root_dir, +max_depth=...)`. It selects the module's deterministic Markdown or JSON +renderer for the requested format and writes the result to stdout (Markdown by +default). Attempt provenance remains on stderr. +`--audit-structure-format` is valid only with `--audit-structure`. + +This mode is deliberately independent from project selection and execution: +pipeline selectors and mutating/execution options (including `--project`, +`--check-all`, and `--list-projects`) are rejected rather than silently +combined. The audit walks the discovered projects up to `--scan-depth` and +uses the read-only inventory/audit modules; it does not run analysis, plotting, +diagram, or promotion steps and does not modify project files. + +The aggregate retains invalid-configuration and execution-boundary-blocked +projects as diagnostic rows instead of silently dropping them. Its report +schema is `figops.project-structure-audit-report.v1`; aggregate and per-project +`proposed_changes` are always empty on this surface. + +Selection follows the canonical matrix in +[`docs/project-structure-contract.md`](project-structure-contract.md): +`invalid`, `boundary_blocked`, `skipped`, and `audit_error` rows are report-only; +ambiguous/heuristic `unknowns` and `proposed_mappings` are candidate-only; and +only explicit, reviewer-supplied `approved_mappings` (plus typed config edits) +can form a copy-only plan. A reviewed dry-run returns a deterministic +`plan_digest` and its `FIGOPS-APPLY-` confirmation token. Apply +requires the identical reviewed inputs and token, and fails closed on stale +identity/configuration, collisions, unresolved dependencies, or token mismatch. +The token proves integrity and exact replay of that plan; it does not prove an +independent human identity, reviewer authority, or attestation, and the +compatibility workflow does not close self-approval. The Phase 6 host-rooted approval +authority contract is defined in the canonical +[`runtime-integrity SSOT`](specs/2026-07-15-project-structure-runtime-integrity-plan.md#phase-6-host-rooted-approval-authority-contract): +it requires a host capability or signature, canonical bindings, currentness and +revocation checks, and fail-closed apply ordering. Secure MCP mode now enforces +the contract with the host-owned process-local `ApprovalAuthorityRoot`, an +opaque `approval_receipt_id`, and a mutation-boundary recheck. The default +compatibility mode remains token-only for backward compatibility; its token, +LLM JSON, and copy/runtime/durable/evidence receipts are not approval. The +production `graphhub_mcp_server.py`/`figops_mcp_server.py` launcher now enables +secure mode through the trusted host-root injection boundary, so the Phase 6 +host-approval gate is satisfied for that launcher. Compatibility constructors +and the historical `GraphHubMCPServer` class remain token-only and are not +release evidence; full release still requires the remaining exact-commit gates. + +During planning, `analyze_dependency_script` output is evidence only. Static +imports/path literals are not role approvals; dynamic paths and parse/read or +unsupported-language failures set the incomplete signal and remain plan +blockers, and no role is guessed from names or extensions. The apply guard +rejects any non-empty `hardcoded_unresolved_references` or +`unresolved_proposals` before copying, even when the reviewed digest/token is +otherwise valid. + +The emitted structure report is diagnostic output, not a runtime manifest, +durable result, or evidence receipt. It describes current structure findings +for review; it must not be treated as a promoted artifact or copied into a +project's `results/` tree as if it were research output. Runtime job state, +logs, caches, snapshots, and detailed manifests remain under the external +runtime root, while durable results and receipts remain under their declared +project role roots. +Plans, digests, and confirmation tokens are likewise control-plane evidence; +they do not move runtime state into `results/` or turn a diagnostic finding into +a durable research result. + ## Why this shape - **Maintainability**: the MCP monolith has been replaced by smaller modules diff --git a/docs/project-structure-contract.md b/docs/project-structure-contract.md index 7deca9e..175adf9 100644 --- a/docs/project-structure-contract.md +++ b/docs/project-structure-contract.md @@ -78,6 +78,134 @@ fails closed when the native guarantee is unavailable. Failure rollback removes only newly created files whose hashes still match the reviewed plan; FigOps never deletes or moves raw inputs. +### Conservative dependency-script evidence + +Before a migration plan is reviewed, project script references may be inspected +with the read-only API +`hub_core.dependency_script_inspection.analyze_dependency_script(script, +suffix=None, *, language=None, script_path=None, role_roots=None)`. The +canonical function (also exposed through descriptive `inspect_*`/`scan_*` +aliases) accepts Python or R source text, or a `Path`, and returns deterministic, +JSON-friendly evidence with these fields: + +- `inspectable`: whether the source was parsed by the bounded Python/R scanner; +- `dependency_scan_incomplete`: whether any part of the dependency surface could + not be resolved safely; +- `static_candidates`: imports and obvious literal file/path references found by + the scanner; and +- `hardcoded_unresolved_references`: dynamic paths, unmapped literals, and + read/parse/language diagnostics that must remain blockers for review. + +The scanner never executes a script, rewrites source, or assigns a role from a +filename, extension, or directory name. A literal path is resolved only when +the caller supplies `role_roots` and the scanner selects its most-specific +declared terminal semantic root. Grouping roots `scripts` and `results` never +clear blockers, and equal-depth terminal matches remain unresolved; otherwise +the path remains a `hardcoded_path` reference. Dynamic +path expressions are reported as unresolved and set +`dependency_scan_incomplete`. Read, unsupported language, or parse failures set +`inspectable: false`, preserve a diagnostic, and set +`dependency_scan_incomplete`; a partially scanned script is therefore +incomplete evidence, not a clean result. The planner folds these references +into `hardcoded_unresolved_references`; scanner output is evidence and a plan +only; unresolved or incomplete scanner findings are plan blockers, never +approved mappings. + +The apply guard is fail-closed even when a plan digest and confirmation token +are valid: `structure_apply.apply_structure_plan` rejects a non-empty +`hardcoded_unresolved_references` **or** `unresolved_proposals` before any +project-root identity check or copy. An unresolved proposal is consequently a +plan blocker, not a warning that can be bypassed by replaying a reviewed token. + +### Finding-to-plan selection matrix + +The audit report is a selection aid, not an approval list. The following matrix +is normative for the transition from a read-only finding to a reviewed copy +plan: + +| Report item | Surface/status | Selection class | Plan/apply effect | +| --- | --- | --- | --- | +| Invalid project/configuration | `audit_status: invalid` | Report-only | Retain the row and errors; do not inspect, propose, or apply mappings until the project is repaired and audited again. | +| Execution-path rejection | `audit_status: boundary_blocked` | Report-only | Retain the row and boundary error; no project files may be selected from this row. | +| Config-less discovery entry | `audit_status: skipped` | Report-only | Retain the discovery evidence; provide a valid project configuration before any plan can be built. | +| Loader or audit exception | `audit_status: audit_error` | Report-only | Preserve the error and rerun after it is resolved; an exception never becomes a mapping candidate. | +| Audited structural finding | `audit.findings[]` (for example `collision`, `stale_reference`, or `provenance_incomplete`) | Report-only diagnostic | Findings inform review and may block a safe plan, but are never copied into `approved_mappings`. | +| Semantic role proposal | `audit.unknowns[]` with one candidate, or `proposed_mappings[]` | Candidate-only | A candidate role, confidence, or destination is a suggestion only; a reviewer must choose the explicit source, destination, and role. | +| Ambiguous/heuristic unknown | An `unknowns[]` candidate with conflicting or heuristic evidence | Report-only candidate | Keep the item unresolved. It may be discussed during review, but it cannot enter a plan until the reviewer supplies an explicit mapping. | +| Unresolved proposal/dependency | `unresolved_proposals[]` or `hardcoded_unresolved_references[]` | Report-only blocker | Keep it unresolved; apply is refused while it can affect a copied artifact. FigOps never guesses or rewrites arbitrary source text. | +| Reviewed mapping | `approved_mappings[]` (with any typed `config_diff`) | Explicit plan input | Only this reviewer-supplied list is eligible to enter a copy-only plan. Every source, destination, and semantic role is validated and bound to declared roots. | +| Reviewed dry-run plan | `plan_digest` and `confirmation_token` | Review checkpoint | The canonical plan digest is deterministic and the returned token binds the exact plan; dry-run writes nothing. | +| Exact reviewed apply | `dry_run: false`, `move_policy: copy` | Apply confirmation | Re-submit the identical reviewed inputs and token. Stale hashes/config, changed inputs, collisions, unresolved dependencies, or token mismatch fail closed. | + +In particular, an ambiguous/heuristic `unknown` remains report-only even when a +candidate role is displayed, while a high-confidence `proposed_mapping` remains +candidate-only. Neither is an implicit approval. +`approved_mappings` must be authored from the reviewed evidence (or an +intentional, documented mapping) and must not be synthesized by replaying the +audit output. + +### Approval-token workflow + +1. Run `figops.describe` with `kind: project_structure` or the CLI + `--audit-structure` mode. Treat `invalid`, `boundary_blocked`, `skipped`, + and `audit_error` rows as report-only. Treat `findings`, ambiguous/heuristic + `unknowns`, `proposed_mappings`, and unresolved proposals as evidence for + review, not as approvals. +2. Request `figops.normalize_project_structure` with `dry_run: true` and + `move_policy: adopt` to inspect candidate mappings. Select only the entries + the reviewer accepts, then submit those as explicit `approved_mappings` (and + any typed compare-and-swap `config_diff`) with `move_policy: copy` and + `dry_run: true`. +3. Record the returned `plan_digest` and `confirmation_token`. The digest is a + SHA-256 of the canonical plan payload (sorted semantic entries and verified + source/config identities, excluding the self-referential digest); the token + is `FIGOPS-APPLY-`. This proves integrity and exact replay of + the reviewed plan, not the independent identity, role, authorization, or + attestation of the person or process that supplied it. It is a review + checkpoint, not a write. +4. Apply only by resubmitting the identical project path, mappings, config edits, + unresolved-reference list, and `plan_digest`-bound token with + `move_policy: copy` and `dry_run: false`. The apply path revalidates source + identity, configuration, containment, collisions, and the token before any + copy, then emits the copy receipt and validation result. Secure MCP mode + (`require_host_approval: true`) additionally requires the host-rooted proof + defined in the canonical SSOT; absent that proof, no copy or config write is + authorized. Compatibility mode remains token-only and is not independent + approval. +5. Verify the receipt and validation. Original inputs remain byte-identical; + cleanup is separate and user-authorized. A plan, digest, or token is control + evidence, not a research result, runtime manifest, or evidence receipt. + +### Phase 4 boundary and Phase 6 authority contract + +Phase 4 keeps the mapping policy explicit: only reviewer-supplied +`approved_mappings` and typed config edits may enter the copy-only plan. The +current `FIGOPS-APPLY-` token binds the exact canonical payload and +the verified source/config identities, but it does not establish independent +human identity, reviewer authority, or an attestation. The current workflow +therefore does not close self-approval; reviewer provenance remains an +out-of-band policy/process requirement rather than a machine-enforced claim. + +The Phase 6 host-rooted authority contract is normative in the canonical +[runtime-integrity SSOT](specs/2026-07-15-project-structure-runtime-integrity-plan.md#phase-6-host-rooted-approval-authority-contract). +It defines the minimum canonical approval payload, host capability/signature +trust proof, currentness and revocation checks, mutation ordering, and +adversarial acceptance criteria. Secure MCP mode (`require_host_approval: true`) +verifies the process-local host-owned `ApprovalAuthorityRoot`, requires an +opaque `approval_receipt_id`, and rechecks the receipt at the mutation boundary; +missing or changed authority fails closed. The production +`graphhub_mcp_server.py`/`figops_mcp_server.py` launcher is the trusted injection +boundary: it creates or receives the host-owned root and enables secure mode. +An embedded host may optionally inject its own root through the constructor-only +`host_authority_root` channel together with `require_host_approval: true`. +Default compatibility mode remains token-only for backward compatibility, so +its plan token, scanner output, unresolved-proposal result, or +copy/runtime/durable/evidence receipt is not independent approval evidence; +compatibility constructors/classes are not Phase 6 or release evidence. +LLM-authored JSON is review input, not host authority. The Phase 6 host-approval +gate is satisfied for the production launcher; full release still depends on +the remaining exact-commit gates. + ## Runtime and durable results Launcher-owned runtime state is external to the project and disposable. Jobs, diff --git a/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md b/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md index 91190bb..3e1d4c8 100644 --- a/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md +++ b/docs/specs/2026-07-15-project-structure-runtime-integrity-plan.md @@ -319,6 +319,192 @@ Organization is a reviewable transaction, not an eager formatter. 8. **Report:** retain rollback manifest. Original cleanup is always a separate, user-authorized operation outside the organizer. +The normative finding-to-plan selection matrix and approval-token syntax live in +[`docs/project-structure-contract.md`](../project-structure-contract.md). In +summary, invalid, boundary-blocked, skipped, and audit-error projects are +report-only; ambiguous/heuristic unknowns and proposed mappings are +candidate-only; and only explicit reviewer-supplied `approved_mappings` plus +typed config edits enter a copy-only plan. A reviewed dry-run fixes a +deterministic `plan_digest` and `FIGOPS-APPLY-` token. Apply must +repeat the identical reviewed inputs with that token and remains blocked by +stale identities, collisions, unresolved dependencies, or token mismatch. +The token proves integrity and exact replay of the canonical plan, not +independent reviewer identity, authority, or attestation; the compatibility +workflow does not close self-approval. A host-issued `approval_receipt` or +equivalent immutable reviewed-plan authority, bound to reviewer identity/role +and the plan digest and rooted in a host trust root, is specified by the Phase 6 +contract below. A process-local implementation now exists: the exact +host-owned `ApprovalAuthorityRoot` mints immutable approval records, and secure +MCP normalization (`require_host_approval: true`) verifies the host receipt and +rechecks it at the mutation boundary. + +The production `graphhub_mcp_server.py` launcher (also used by +`figops_mcp_server.py`) is the trusted injection boundary: it creates or receives +the host-owned process-local root, sets `require_host_approval: true`, and passes +the root through the constructor-only `host_authority_root` channel. Tool +arguments, project configuration, plan JSON, environment variables, runtime +manifests, and durable/evidence receipts cannot create, select, or replace that +root. An embedded host may opt into the same secure mode by supplying its own +host-owned root through that constructor-only channel together with +`require_host_approval: true`; if the secure flag/root are omitted, the embedded +constructor remains compatibility/token-only. The Phase 6 host-approval +gate is therefore satisfied for the production launcher; full release still +requires the remaining exact-commit gates below. +Audit reports, plans, digests, and tokens are control evidence, not runtime +manifests, durable results, or evidence receipts; runtime remains externally +rooted and disposable. + +### Phase 6 host-rooted approval authority contract + +Phase 6 is the normative authority boundary for structure migration. It closes +the self-approval gap without making the planner, an LLM, or a project file an +authority. Secure mode (`require_host_approval: true`) now enforces this +contract: missing/untrusted roots, missing or invalid receipts, stale/revoked +records, binding mismatches, and mutation-boundary revocation fail closed. The +default compatibility mode remains token-only for backward compatibility; its +valid plan and `FIGOPS-APPLY-` token prove replay integrity only +and MUST NOT be described as independent approval or release evidence. The +production `graphhub_mcp_server.py`/`figops_mcp_server.py` launcher enables +secure mode with a host-owned process-local `ApprovalAuthorityRoot`, so the +Phase 6 host-approval gate is satisfied for that launcher. The compatibility +constructor/class remains token-only and cannot satisfy the Phase 6 or release +gate; full release still depends on the remaining exact-commit gates. + +The following prove integrity, provenance, replay, or execution lineage, but do +**not** prove approval or reviewer authority: the `FIGOPS-APPLY-` +token; any source/config/environment provenance; a copy, runtime, durable, or +evidence receipt; an audit report or plan; and any LLM-authored JSON field such +as `approved`, `reviewer`, or `authorization`. These values may be inputs to +review, but none can authorize mutation when presented by the planner, model, +project, or runtime filesystem. + +#### Minimum canonical approval payload + +The host approval is a canonical `figops_approval/1` payload. Its signed or +capability-bound bytes include every field below (empty lists are still hashed, +not omitted): + +```json +{ + "schema": "figops_approval/1", + "receipt_id": "", + "plan_digest": "", + "project_root_identity": "", + "config_identity": { + "relative_path": "project_config.yaml", + "sha256": "" + }, + "approved_mappings_digest": "", + "config_diff_digest": "", + "unresolved_digest": "", + "reviewer": { + "subject": "", + "role": "" + }, + "issued_at": "", + "expires_at": "", + "currentness": { + "state": "current", + "checked_at": "", + "revocation_epoch": "" + }, + "revocation": { + "state": "not_revoked", + "checked_at": "" + } +} +``` + +The canonicalization rules are deterministic (UTF-8 JSON, sorted object keys, +no insignificant whitespace, fixed digest encoding). `project_root_identity` +is derived from the launcher-resolved, normalized root and its stable filesystem +identity; it is not accepted from project configuration. `config_identity` is +the reviewed config path relative to that root plus its exact bytes; for a +config-less legacy project, an explicit `null` identity is bound into the plan +and cannot be supplied later by the model. The three +component digests bind the exact mappings, typed compare-and-swap config edits, +and unresolved-reference set used to compute `plan_digest`; changing order, +content, or even an empty-versus-omitted value changes the digest. Reviewer +subject and role are assertions only when the host trust policy authorizes them. +The process-local implementation stores the unresolved component as separate +digests for `hardcoded_unresolved_references` and `unresolved_proposals` (plus +the reviewed-entry digest); these are the concrete encoding of the minimum +`unresolved_digest` binding and are all rechecked. + +The payload MUST be accompanied by one of these out-of-band authority proofs: + +1. a host capability handle resolved and consumed through a launcher/host + authority channel, with the host returning the canonical payload and its + current, non-revoked status; or +2. a host signature over the canonical payload, verified against a + launcher/operator-pinned trust root and key identifier (for example, + `trust_root_id`, `key_id`, `algorithm`, and detached `signature`). + +The current secure MCP implementation uses the first form through a +process-local `ApprovalAuthorityRoot`: the root object is supplied by the host +at server construction, cannot be copied, and is required by object identity +when `verify_approval_authority` checks an `approval_receipt_id`. The secure +`figops.normalize_project_structure` schema accepts only that opaque receipt +ID; self-described approval JSON is rejected. The default compatibility +constructor leaves `require_host_approval` false and therefore intentionally +retains token-only behavior; compatibility apply is not an independent approval +path and must not be presented as one. + +Trust roots, capability validation, reviewer-role policy, revocation state, and +currentness are host/operator state. They MUST NOT be supplied by the LLM, +project config, plan JSON, runtime manifest, or a durable/evidence receipt. A +missing, unknown, malformed, expired, revoked, stale, or unverifiable proof +fails closed. An LLM response that contains the same JSON without a host proof +is untrusted data, even if it says `approved: true` or reproduces a valid token. + +#### Apply ordering and revalidation + +Apply performs these steps in order, with no file or config mutation before step +4 succeeds: + +1. Parse the reviewed plan and host payload; reject non-canonical or + self-described authority fields. +2. Recompute `plan_digest`, `approved_mappings_digest`, `config_diff_digest`, + and `unresolved_digest`; verify the `FIGOPS-APPLY-` token, + project-root identity, config identity, containment, and all stale/collision + and unresolved-dependency guards. +3. Verify the host capability or signature against the pinned trust root, + enforce the reviewer role, and require `issued_at <= now < expires_at` plus + host-confirmed current, non-revoked `receipt_id`/`revocation_epoch`. +4. Acquire the apply transaction lease and repeat the identity, digest, and + host-currentness checks at the mutation boundary. A capability is consumed + according to host policy; a revoked, expired, or otherwise changed approval + aborts before staging. +5. Execute the existing copy-only transaction: destination-filesystem sibling + staging, containment/hash verification, fsync where supported, native + same-filesystem no-replace publication, and typed config CAS. Revalidate + approval currentness before any subsequent mutation, then emit a copy + receipt that records the approval `receipt_id` as lineage, never as a + replacement for the approval proof. + +#### Adversarial acceptance criteria + +In secure MCP mode, a Phase 6 implementation must fail closed on the following +cases before any copy or config write. Compatibility mode may retain its +historical token-only behavior for backward compatibility, but it must expose no +host-approval status and must never describe that behavior as independent +approval or as satisfying the Phase 6/release gate. + +| Adversarial input | Required result | +| --- | --- | +| Valid plan/token/provenance or durable/runtime/evidence receipt but no host proof | Secure mode rejects as unauthorised with no mutation; compatibility mode may follow its legacy token-only path but cannot claim approval. | +| LLM JSON containing `approved: true`, reviewer fields, or a forged receipt | Reject; model/project data is not an authority. | +| Signature over a payload whose plan, root, config, mappings, diff, unresolved set, reviewer, or validity window changed | Reject signature/digest mismatch. | +| Approval for another project root, config hash, plan digest, or mapping/diff/unresolved digest | Reject binding mismatch. | +| Unknown trust root/key, malformed capability, missing host policy, or unavailable authority channel | Reject and fail closed. | +| Expired, revoked, stale, non-current, or replayed one-time capability | Reject before staging; preserve all existing files. | +| Source/config identity, collision, unresolved dependency, or root containment changes after approval | Recompute and reject before mutation. | +| Revocation or expiry races after preflight | Mutation-boundary/currentness recheck aborts before the affected mutation. | + +The focused adversarial suite MUST exercise each row with both CLI and MCP apply +surfaces where available, and must verify byte-identical originals, no partial +config rewrite, and no destination clobber on every rejection. + Low-confidence, multi-role, content-sensitive, unreferenced, and collision cases stay unresolved. Extension-only classification may not cross a role boundary. Any hard-coded script/import/config dependency that cannot be represented as a @@ -326,6 +512,37 @@ reviewed compare-and-swap edit remains an `unresolved_dependency`. Migration apply is blocked while even one such dependency can affect a copied artifact; warnings are insufficient and the tool may not guess or rewrite arbitrary source. +### Conservative dependency-script inspection + +The migration planner uses the read-only +`hub_core.dependency_script_inspection.analyze_dependency_script` API for +bounded dependency evidence. It accepts Python or R source text (or a `Path`) +and optional `suffix`/`language`, `script_path`, and explicit `role_roots` +arguments. The deterministic JSON-friendly result contains `inspectable`, +`dependency_scan_incomplete`, `static_candidates`, and +`hardcoded_unresolved_references`. Static candidates include imports and +obvious literal file/path references, but they are evidence only: the scanner +never executes a script, rewrites source, or guesses `raw`, `results`, script, +or any other semantic role from a name, extension, or directory. + +A literal path is cleared only through the most-specific declared terminal +semantic root selected by the caller-provided `role_roots` mapping. Grouping +roots `scripts` and `results` never clear blockers, and equal-depth terminal +matches remain unresolved. Otherwise it remains an unresolved `hardcoded_path` +entry. Dynamic path expressions remain unresolved and set +`dependency_scan_incomplete`; read errors, unsupported languages, and parse +failures set `inspectable: false`, preserve a diagnostic, and set the same +incomplete signal. Thus a partial or failed scan is incomplete evidence and a +plan blocker, not a clean pass. The planner carries these entries into +`hardcoded_unresolved_references`; scanner output never becomes an approved +mapping. + +`structure_apply.apply_structure_plan` enforces the corresponding fail-closed +guard: a non-empty `hardcoded_unresolved_references` **or** +`unresolved_proposals` rejects the plan before project-root identity checks or +copy, even when the plan digest and confirmation token are valid. An unresolved +proposal is therefore a plan blocker, not a warning or an approval surrogate. + ## 8. Seven P1 corrections ### P1-1 Calculation evidence binds to the real artifact @@ -433,7 +650,7 @@ approval, or fulfillment of the complete Definition of Done. | WP3 | implementation complete | Neutral v1.1/v2 defaults, independent validation targets, and artifact-derived policy measurements are integrated. | | WP4 | implementation complete | v1.1 role/DAG/alias validation and legacy 1.0 in-memory resolution are integrated with config parsing and templates. | | WP5 | implementation complete | Scaffolding and normalization consume the shared `project_layout.py` inventory. | -| WP6 | implementation complete | `structure_inventory`, `structure_audit`, `structure_plan`, and `structure_role_binding` use semantic/reference precedence and bind approved destinations to declared roots. | +| WP6 | implementation complete | `structure_inventory`, `structure_audit`, `structure_plan`, and `structure_role_binding` use semantic/reference precedence and bind approved destinations to declared roots. The conservative `dependency_script_inspection.analyze_dependency_script` API supplies deterministic Python/R dependency evidence; parse/dynamic/incomplete findings remain blockers, and apply rejects non-empty `unresolved_proposals` fail-closed. | | WP7 | implementation complete | Reviewed application is copy-only, token/CAS guarded, rollback-aware, and publishes a verified sibling stage only through the native consuming no-replace primitive; race winners are preserved. | | WP8 | implementation complete; independent adversarial gate green | Runtime containment, pre-execution external-raw verification, eligible-result promotion, staged durable publication, and runtime-independent receipt verification are integrated across CLI and MCP producers. Handle-bound rollback deletion closes the hash-to-unlink swap window; the independent rollback suite passed 34 tests with two platform skips. | | WP9 | implementation complete | v2 exposes structure detail through `figops.describe`; compatibility apply remains write-gated without expanding the seven-tool default surface. | diff --git a/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md b/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md new file mode 100644 index 0000000..f97bc31 --- /dev/null +++ b/docs/specs/2026-07-20-ai-native-figure-integrity-plan.md @@ -0,0 +1,714 @@ +# FigOps AI-Native Figure Integrity Plan + +**Status:** canonical successor plan; Phase 1 human review foundation, Phase 2 +policy/workflow contracts, and Phase 3 pure domain evaluator/receipt foundation +are implemented; Phase 4 optional write-gated MCP review recording and guarded +native promotion integration are implemented. Phase 5 migration/release +governance remains pending. + +**Date:** 2026-07-20 + +**Contract targets:** `figops-human-review/1` and `figops-promotion-gate/1` +**Baseline:** `v0.20.0` is published on PyPI and as a GitHub Release. This plan +governs a later additive increment; it does not reopen or relabel that release. + +## 1. Authority and relationship to current release work + +This is the single source of truth for the next integrity increment: a closed +human-review/signoff receipt, a promotion gate that consumes it, one policy +resolution path, and the associated workflow and release governance. It is an +additive successor, not a rewrite of the published-release corrective work. + +The [2026-07-15 project-structure and runtime-integrity plan](2026-07-15-project-structure-runtime-integrity-plan.md) +is the historical corrective baseline for PR #224 and the published `v0.20.0` +release. Its role contract, runtime/result boundary, durable receipts, raw +integrity, copy-only organization, acceptance matrix, and release-gate evidence +remain binding foundation facts. This plan does not weaken, duplicate, or move +those requirements. + +The [architecture inventory](../architecture.md) and [roadmap](../ROADMAP.md) +contain the contemporaneous `v0.20.0` release-candidate implementation record; +their historical status wording does not supersede the published-release fact +above. The earlier [AI-native rearchitecture plan](2026-07-14-ai-native-figops-rearchitecture.md) +remains implemented context for bounded evidence, previews, policy projections, +and the v2/compatibility surface. Where those documents describe a current +implementation, they are evidence for this plan; where this plan specifies a +future approval lifecycle, this plan controls that new scope. + +The governing rule is: + +> Tools prove bounded facts and enforce declared boundaries. Models assemble +> evidence and propose work. A named accountable human alone accepts the +> scientific and communicative claim; promotion records that acceptance but +> never manufactures it. + +## 2. Purpose and success condition + +FigOps must remain useful to an AI-native research workflow without converting +an LLM's persuasive summary, a green renderer, or `manual_review_needed=false` +into a false publication claim. The target state is a reproducible, reviewable +chain from declared source data and scripts through a rendered artifact to an +explicit, revocable human decision that is bound to exactly those bytes and +their evidence. + +At completion, a protected publication promotion is possible only when all of +the following are true: + +1. Existing structural, provenance, claim, policy, and no-replace promotion + invariants pass. +2. The candidate has a closed, schema-valid review receipt whose subject digest + recomputes from the exact durable artifact and referenced evidence. +3. The receipt is an affirmative decision by an authorized human reviewer for + the requested decision scope, has not expired or been superseded, and + records no unresolved required concerns. +4. A single deterministic promotion-gate evaluator records every satisfied, + failed, unavailable, and explicitly waived gate before the existing durable + promotion primitive is invoked. + +This is evidence of a FigOps-controlled promotion decision, not a guarantee of +scientific truth, ethical approval, co-author consent, institutional approval, +or publisher acceptance. + +## 3. Responsibility boundary + +The terms **tool**, **LLM**, and **human** identify responsibility, not a +particular process. An LLM may be used by a human; it must still not be credited +as the accountable reviewer. A tool may make a deterministic recommendation; +it must still not claim scientific approval. + +| Decision area | Tool/kernel responsibility | LLM responsibility | Human responsibility | +|---|---|---|---| +| Inputs and execution | Contain paths; preserve raw identity; prefetch/verify authorized external raw; execute declared producers; hash inputs, scripts, config, environment, and outputs | Propose input mapping and run plan; explain failures | Confirm data selection is scientifically appropriate and authorized | +| Figure construction | Enforce declared I/O, timeouts, format facts, policy measurement, and immutable candidate identity | Select encodings; author/revise code; inspect bounded previews; propose targeted changes | Decide whether the figure communicates the intended result honestly | +| Evidence | Produce/recompute facts, typed lineage, policy projections, and durable receipts; reject missing or malformed evidence | Correlate evidence; identify uncertainty; prepare a review brief; never fill missing evidence by inference | Assess whether evidence supports the scientific claim and whether caveats are adequate | +| Policy | Resolve versioned policy inputs deterministically and disclose defaults, inheritance, and opt-outs | Recommend a policy only when supplied constraints support it | Choose lab/venue policy and approve a justified scoped exception where policy permits | +| Review and release | Verify review-receipt closure, subject binding, expiry/revocation, and gate precedence; perform no-replace promotion | Request review and summarize what changed; never self-sign or assert approval | Record the decision, identity/role assertion, scope, concerns, and authorization to promote or decline | + +### 3.1 Three classes of conclusions + +1. **Enforceable invariants** are binary system promises. They include + containment, role resolution, raw/source identity, artifact hashes, + schema validity, declared producer/output binding, runtime/result + disjointness, no-replace promotion, receipt closure, review subject digest, + signature/identity verification when configured, and gate precedence. A + missing, malformed, stale, or unverifiable invariant fails closed. +2. **Assistive evidence** is objective but incomplete information: visual + preflight, geometry diagnostics, regression deltas, policy measurements, + claim-inventory candidates, bounded previews, and LLM-written review briefs. + It may request revision or review. It cannot become an approval solely by + aggregation, confidence score, or absence of warnings. +3. **Human scientific judgement** covers causal interpretation, statistical + appropriateness in context, claim strength, visual honesty, accessibility + tradeoffs, authorship/consent, ethical or legal obligations, venue fit, and + submission. FigOps records an explicit decision about this judgement; it + neither computes nor substitutes for it. + +`needs_review` remains a queue state, not approval. `manual_review_needed=false` +means only that its producing checks did not request manual review; it is never +a signoff, a publishability verdict, or a bypass of an explicit review policy. + +## 4. Canonical research object model + +The human-facing project model is intentionally simple; declared roles, not +directory spelling or file extension, give it meaning. + +```text +project/ +├─ raw/ immutable or externally governed scientific inputs +├─ hub_scripts/ tracked analysis, figure, and shared source +├─ results/ durable derived data, tables, figures, evidence, publication +└─ (external runtime) jobs, snapshots, cache, logs, manifests, previews, temp +``` + +`raw`, `hub_scripts`, and `results` retain the `figops-project-v1.1` role +semantics specified by the 2026-07-15 SSOT. `runtime.*` remains external, +disposable, and path-disjoint from project and durable result roots. A runtime +manifest is operational evidence; a receipt is a compact durable projection. +No review receipt may embed a runtime path, raw data content, secret, or preview +blob. It may refer only to allow-listed durable logical IDs, content hashes, +policy IDs/versions, opaque manifest IDs, and compact evidence digests. + +The new review receipt is a `result.evidence` artifact. It does not replace the +existing durable lineage receipt: + +| Record | Owner and lifetime | Purpose | +|---|---|---| +| Runtime manifest | runtime; disposable | Detailed execution, diagnostics, logs, and preview references | +| Durable lineage receipt | `results/evidence`; durable | Binds raw/scripts/config/environment/input/output/claim lineage while remaining independent of runtime deletion | +| Human review receipt | `results/evidence`; durable and append-only | Binds a human decision to a candidate subject digest and the reviewed evidence set | +| Promotion-gate receipt | `results/evidence` and, when promoted, publication bundle; durable | Records deterministic admission/denial from the candidate, policy, lineage, and human review receipt | +| Publication bundle | `results/publication`; immutable after promotion | Frozen promoted figure(s) plus the minimum verified receipts and manifest | + +An existing result figure can remain reviewable without a signoff. It becomes +**promotion eligible** only through the existing machine eligibility plus the +new gate when the selected policy requires human signoff. It becomes +**promoted** only after the gate receipt and existing native no-replace +publication mechanism succeed. These terms are deliberately not synonyms for +scientific correctness or publisher acceptance. + +## 5. Ranked gap register + +This register records the observed delta from the published `v0.20.0` baseline, +not an assertion that current controls are absent. Existing safeguards named in +the evidence column are retained. + +| Rank | Gap | Why it matters | Current evidence/control | Required resolution | +|---:|---|---|---|---| +| P0 | No closed human review/signoff receipt or subject-bound approval lifecycle | A future promotion surface could mistake readiness or a model statement for approval | `publication_readiness/1` ends at `needs_review`; `artifact_audit` is explicitly non-approving | Add a closed review receipt, verifier, revocation/supersession semantics, and mandatory gate consumption | +| P0 | Existing result promotion admits machine-eligible project renders before an explicit review-policy gate | Machine eligibility is necessary but cannot represent human scientific acceptance | `hub_core/result_promotion.py` checks claim/policy/manifest eligibility and writes durable lineage receipt | Put a fail-closed promotion-gate admission boundary before promotion; keep the existing primitive unchanged | +| P1 | Policy resolution is distributed across render selection, artifact measurement, research-ops evidence, and readiness | Defaults, inheritances, and opt-outs can become inconsistent or impossible to audit as one decision | `artifact_policy_measurement.py`, `provenance_inputs.py`, and evidence `resolved_policy` snapshots | Add one canonical resolver/provenance model; migrate callers through compatibility adapters | +| P1 | No first-class distinction between exploratory artifacts and execution candidates | Draft work can be over-read as reproducible or promotable | Runtime boundary, durable receipt, and read-only readiness already distinguish evidence availability | Add declared workflow intent/state and promote only execution candidates with complete evidence | +| P1 | Review scope, reviewer authority, and conflict/exception handling are not encoded | A generic yes/no comment cannot establish what was reviewed or whether a waiver is authorized | Existing `manual_review_needed` and readiness findings surface uncertainty | Specify narrow decision scopes, role assertions, concern disposition, and policy-governed exception receipts | +| P2 | Legacy projects are readable/render-disabled but have no migration path into signoff-gated promotion | Forcing a schema upgrade would damage compatibility; silently exempting them would weaken trust | `legacy_structure_resolver.py`; legacy render refusal in CLI/MCP | Preserve legacy behavior; require explicit v1.1 migration plus fresh execution for new promotion workflows | +| P2 | CI proves code behavior but does not yet run approval-lifecycle or stale-review adversarial matrices | A new human-facing state machine needs resistance to replay, tampering, expiry, and profile drift | Current CI gates platform containment, actual-R, tests, Ruff; release discipline checks exist | Add deterministic lifecycle tests and release evidence gates without expanding ordinary PR workload unnecessarily | +| P3 | AI review briefs lack a stable non-authoritative exchange contract | Useful summaries can obscure missing evidence or sound like an approval | Bounded previews/evidence and non-approval language are present | Define an optional, redacted review-brief schema that tools label as assistive only | + +P0 blocks any release that exposes signoff-gated promotion. P1 blocks the +default-on policy or execution workflow. P2 may ship only with an explicit +owner and compatibility evidence. P3 is not a prerequisite for the core gate. + +## 6. Target architecture + +### 6.1 Closed human review receipt + +`figops-human-review/1` is a closed, canonical JSON DTO. Unknown keys, +duplicate JSON keys, non-finite values, absolute paths, raw values, runtime +paths, and mutable external references are rejected. It is an append-only +evidence record; corrections create a new receipt that explicitly supersedes an +older receipt. No mutation in place is allowed. + +The minimum receipt shape is conceptually: + +```json +{ + "schema_version": "figops-human-review/1", + "receipt_id": "review:sha256:...", + "decision": "approve_for_promotion", + "decision_scope": "figure_scientific_and_communication", + "subject": { + "project_id": "opaque-project-id", + "artifact_id": "result.figure:...", + "artifact_sha256": "...", + "lineage_receipt_sha256": "...", + "evidence_digest": "...", + "resolved_policy_digest": "...", + "subject_digest": "..." + }, + "reviewer": { + "principal_id": "configured-opaque-id", + "role": "scientific_reviewer", + "authority_assertion": "lab-policy/1" + }, + "reviewed_at": "2026-07-20T00:00:00Z", + "expires_at": "2026-10-18T00:00:00Z", + "concerns": [], + "waivers": [], + "supersedes": null, + "integrity": {"canonical_sha256": "..."} +} +``` + +The concrete schema may add allow-listed fields only through a schema-version +change. Its canonicalization is fixed, not left to an implementation: + +1. Parse UTF-8 without a BOM; reject duplicate object keys, non-finite numbers, + unsupported JSON values, and text that is not Unicode NFC. Normalize every + accepted string to Unicode NFC before validation. Times are RFC 3339 UTC with + a `Z` suffix and seconds precision. SHA-256 strings are lowercase hexadecimal. +2. Form the **review payload** by removing exactly the top-level `receipt_id` + and top-level `integrity` members. Validate the remaining closed DTO, + including its nested objects and ordered arrays, before hashing it. +3. Serialize that payload as UTF-8 JSON with lexicographically sorted object + keys by Unicode code point, compact separators `,` and `:`, no insignificant + whitespace, no escaping of non-ASCII characters, and no trailing newline. + Array order is semantic and is never sorted. Let `D` be the lowercase + SHA-256 digest of these bytes. +4. Require `receipt_id` to be exactly `review:sha256:` and `integrity` to be + exactly `{ "canonical_sha256": "" }` after canonical JSON parsing. The + two fields are derived witnesses of the same payload, not independent input. +5. Any local attestation or future signature signs the review-payload bytes + from step 3. It is stored outside the receipt payload or in a separately + versioned, non-self-signed envelope. A verifier recomputes `D` before it + considers the attestation. + +This makes the self-reference rule exact: neither `receipt_id` nor +`integrity.canonical_sha256` participates in `D`; all scientific, policy, +reviewer, scope, concern, waiver, expiry, and supersession content does. + +The receipt must also define these exact semantics: + +- Decisions are `approve_for_promotion`, `request_revision`, or `decline`; + only the first can satisfy a signoff-required gate. +- A receipt has one immutable subject digest. It binds the durable artifact + SHA-256, durable lineage receipt digest, normalized evidence digest, resolved + policy digest, project/figure logical identity, and decision scope. Any + changed input produces a different subject and requires a fresh review. +- Reviewer identity is a verified principal when an organization identity + provider is configured; otherwise it is a clearly labeled local attestation. + A local attestation can satisfy only policies that explicitly allow it. Free + text names alone are not an authorization mechanism. +- `authority_assertion` names the policy/role binding that permits the recorded + scope. It is a verifiable policy fact, not an LLM-produced label. +- Concerns are closed typed records. Each is resolved, waived by an authorized + exception, or blocks approval. A waiver contains policy rule, rationale, + authorized principal/role, subject digest, and expiry. A waiver never + suppresses an invariant, a P0/P1 security failure, or a required scientific + signoff. +- Revocation and supersession are durable, append-only records. A revoked, + expired, non-current, malformed, or mismatched receipt cannot pass the gate. + The first delivery may support supersession by local receipt index; remote + revocation synchronization is an optional later integration, not an assumed + capability. +- The tool verifies receipt closure, `D`, and the applicable attestation before + use. A verifier never trusts a supplied `receipt_id` or integrity digest. + +The receipt records an accountable decision but intentionally cannot prove the +reviewer actually looked at pixels, understood the experiment, had all required +coauthor consent, or satisfied an external journal process. Those remain human +and organizational responsibilities. + +### 6.2 Deterministic promotion gate + +`figops-promotion-gate/1` is a pure domain evaluator plus a narrow admission +integration. Its inputs are: declared workflow intent, candidate artifact and +hash, existing verified runtime manifest/evidence, existing durable lineage +receipt, canonical resolved policy, optional valid review receipt, and requested +destination. The pure evaluator returns a complete stable list of gates and +exactly one of: `blocked`, `needs_revision`, `needs_review`, or `eligible`. + +Precedence is fixed: + +1. malformed, missing, untrusted, escaped, stale, or mismatched evidence; + failed invariant; invalid policy; or invalid receipt => `blocked`; +2. failed required automated or unresolved non-waivable finding => + `needs_revision`; +3. selected policy requires signoff and no matching current affirmative receipt + exists => `needs_review`; +4. all required gates and required signoff pass => `eligible`. + +The gate does not copy artifacts, modify reviews, or infer waivers. The +integration calls the current `result_promotion`/`durable_promotion` path only +after the pure gate emits `eligible`; it then persists a promotion-gate receipt +whose subject and evidence digests match the reviewed candidate. The promotion +operation, not the evaluator, may report `promoted` only after the existing +native no-replace durable promotion returns success. A failed or raced +destination is never presented as promoted. + +### 6.3 Central policy resolution and opt-outs + +All policy decisions must flow through a single domain resolver. The proposed +new `hub_core/policy_resolution.py` owns a versioned `ResolvedPolicySet` with +one canonical digest. It evaluates inputs in this decreasing authority order: + +1. immutable kernel invariants (not opt-out capable); +2. trusted launcher/operator policy; +3. repository/lab policy, when configured and verified; +4. declared project policy; +5. explicit per-render/per-promotion selection. + +This order is executable rather than advisory. Every shipped policy parameter +declares one merge operator in its schema: `require` (boolean OR), `minimum` +(numeric maximum), `maximum` (numeric minimum), `allowed_set` (set +intersection), `exact` (all specified values must be equal), or `selection` +(the requested value must belong to the intersection of allowed values). The +resolver rejects an unknown parameter or operator. It accumulates constraints +from every applicable layer; an empty intersection, an impossible numeric range, +an unequal `exact` value, a non-membership selection, or a type/version mismatch +is `POLICY_CONFLICT` and blocks execution/promotion. It never chooses a winner +by source order. + +An opt-out is a typed request only for a parameter whose schema declares +`opt_out_allowed=true`. It resolves to disabled only when no higher layer emits +`require=true` for that parameter and no applicable `minimum`, `maximum`, +`allowed_set`, or `exact` constraint is violated. Exceptions and waivers do not +participate in policy resolution and cannot change a resolved parameter value. +They are instead gate inputs: an exception must name one emitted policy finding +code, the exact candidate subject digest, an authorized principal/role, +rationale, and expiry; the gate accepts it only when that finding's parameter +schema declares `waivable=true`. Kernel-invariant parameters declare both +`opt_out_allowed=false` and `waivable=false`. + +The resolved output lists every candidate constraint and records `value`, +`merge_operator`, `source`, `policy_id`, `version`, `opt_out_requested`, and +`opt_out_accepted`. It is canonicalized with the same JSON rules stated for +review payloads, except it has no derived receipt fields; its SHA-256 is the +policy-set digest. Ambiguity, unknown policy versions, or an invalid policy +source therefore fail closed. + +Existing module defaults and explicit `false` research-ops opt-outs remain +backward compatible. They migrate as explicit resolved facts, preserving the +current source distinctions (for example `module-default`, `project_config`, +and `explicit_project_opt_out` where already emitted). The following are never +opt-out capable: path containment, raw/producer verification where selected, +schema/receipt integrity, no-replace promotion, runtime/result disjointness, +or a human signoff demanded by the resolved promotion policy. A policy may +choose that a class of exploratory result does not require signoff, but it must +say so explicitly and it cannot call that result promoted or publication-ready. + +During migration, the existing singular evidence `resolved_policy` field stays +readable. It becomes a compatibility projection of the canonical policy set for +render-policy consumers; it must not be duplicated under competing names. + +### 6.4 Exploration versus execution + +The workflow has explicit intent rather than accidental status inferred from a +directory: + +| Mode | Permitted work | Required evidence | Promotion meaning | +|---|---|---|---| +| `exploration` | Inspect data, author scripts, render drafts, revise from bounded previews, use advisory diagnostics | Containment and safe render evidence; incomplete provenance is surfaced, not invented | Never eligible or promotable; no signoff is requested as a substitute for missing execution evidence | +| `execution` | Run declared project producer against declared inputs under the role/runtime contract | Complete required provenance, claim/measurement policy evidence, candidate identity, and durable lineage receipt | May become `needs_review`, `eligible`, then `promoted` only through the full gate | +| `review` | Read exact candidate/evidence, record decision/concerns/waivers | Closed review receipt bound to an execution candidate | Cannot alter candidate bytes or evidence; a new candidate requires a new review | +| `promotion` | Evaluate admission and invoke existing no-replace result publication | Passed deterministic gate and, where required, current signoff receipt | Produces immutable publication bundle and promotion receipt, or fails without replacement | + +Exploration may produce durable draft outputs if the current result contract +allows them, but the workflow marks them `non_promotable` and preserves that +fact in the evidence. Execution does not make a figure scientifically correct; +it makes it a well-bound candidate for review. + +For compatibility, workflow intent resolves by the operation rather than by a +silent global default: + +| Existing path without `workflow.intent` | Resolved intent | Source and effect | +|---|---|---| +| `orchestrator.py --project ... --step all`, `analysis`, or `plot` on an active project | `execution` | `compatibility-project-execution`; preserves the current declared project-pipeline behavior and remains subject to all current evidence gates | +| MCP `figops.render_project_script` or `figops.render_project_figure` on an active project | `execution` | `compatibility-project-execution`; preserves current project-render and machine-promotion eligibility behavior until a selected policy requires the new review gate | +| MCP one-call render paths (`figops.render_basic_csv`, `figops.render_csv_graph`, and `figops.render_csv_multipanel`), previews, and direct draft render helpers | `exploration` | `compatibility-direct-exploration`; output is non-promotable unless the caller moves to an explicit declared project execution | +| Read, inspect, audit, validation, and readiness paths | no execution intent | read-only; they create no candidate and cannot make a result promotable | +| `project.status: legacy` or an existing refused legacy render path | unchanged legacy behavior | rendering stays disabled where currently disabled; no compatibility intent bypass exists | + +New scaffolded/configured projects must write `workflow.intent: execution` +explicitly. A caller may select `exploration` explicitly for a project draft, +but no direct-render or legacy compatibility route may select `execution` merely +by supplying a review receipt. Every resolved intent and its source enters the +policy/evidence projection. + +### 6.5 Legacy behavior + +`project.status: legacy` retains the existing render-disabled behavior. The +in-memory legacy structure resolver remains read-only. Legacy aliases and +compatibility defaults continue to behave as documented; no new aliases are +added solely for approval or promotion. + +A legacy project cannot obtain a new signoff-gated publication promotion from +historical artifacts. The migration is explicit: adopt the v1.1 role contract, +declare policy/workflow intent, run a fresh contained execution, produce current +lineage/evidence, and review the resulting candidate. The migration never +moves, rewrites, or retroactively certifies legacy bytes. Read-only readiness +evaluation of legacy evidence stays available and reports its limits. + +## 7. Phased work packages + +Each package is a coherent change with a named owner and an independent review. +No package changes the published `v0.20.0` corrective baseline or its release +state. Module names below distinguish existing targets from planned new modules. + +### Phase 0 — Contract lock and adversarial fixtures + +**Goal:** freeze public semantics before a write surface exists. + +- Record this plan's schema-closure decisions in a Phase 0 implementation + receipt that explicitly references this SSOT, and add machine-readable + fixtures under `tests/fixtures/figure_integrity/` (planned). The receipt + carries evidence only; it does not become a competing authority. +- Inventory current producer and receipt shapes in existing + `hub_core/evidence_contract.py`, `hub_core/durable_receipt.py`, + `hub_core/result_promotion.py`, `hub_core/publication_readiness.py`, and + `hub_core/provenance_inputs.py`; do not change producers in this phase. +- Establish fixture cases for canonicalization, duplicate keys, absolute paths, + malformed SHA-256, stale/expired/withdrawn/superseded review, subject mismatch, + policy drift, reviewer-role mismatch, replay to another figure, blocked + waiver, and no-signoff exploratory results. + +**Likely tests:** new `tests/test_human_review_receipt.py`, new +`tests/test_promotion_gate.py`, plus existing `tests/test_durable_receipt.py`, +`tests/test_evidence_contract.py`, `tests/test_claim_boundaries.py`, and +`tests/test_release_discipline.py`. + +**Exit:** an approved schema/precedence fixture matrix exists; each field has an +owner, privacy classification, and canonicalization rule; no runtime path or +approval claim can enter an existing receipt accidentally. + +### Phase 1 — Closed review receipt domain + +**Goal:** implement and verify `figops-human-review/1` without promotion writes. + +- Add planned `hub_core/human_review_receipt.py` for DTO construction, + canonical bytes/digest, strict parsing, subject binding, expiry, concern, + supersession, and revocation-index validation. +- Add planned `hub_core/human_review_identity.py` for a minimal verifier + interface. The first implementation supports a local policy-controlled + attestation and a test verifier; it must not silently claim federated identity + verification. +- Reuse `hub_core/durable_receipt.py` opaque-ID/redaction discipline and + `hub_core/evidence_contract.py` closed-envelope validation rather than + serializing arbitrary mappings. +- Add a read-only review inspection surface only after the domain contract is + stable, likely through existing `hub_core/mcp/tools/readiness_tools.py` and + `hub_core/mcp/schemas.py`; do not add a `graphhub.*` alias. + +**Likely tests:** new receipt tests above; existing +`tests/test_mcp_publication_readiness.py`, `tests/test_mcp_preview_resources.py`, +`tests/test_workflow_security.py`, and `tests/test_claim_boundaries.py` for +non-approval wording. + +**Exit:** receipt validation rejects all hostile fixtures; subject digest +recomputes; a clean automatic readiness report cannot be transformed into an +approval; no write tool or promotion behavior changes. + +### Phase 2 — Canonical policy resolver and workflow intent + +**Goal:** make policy selection and allowed opt-outs explainable from one +digestible source. + +- Add planned `hub_core/policy_resolution.py` and + `hub_core/workflow_intent.py`; provide a compatibility adapter for existing + `resolved_policy` evidence. +- Refactor only through focused callers: existing + `hub_core/artifact_policy_measurement.py`, `hub_core/render_evidence.py`, + `hub_core/provenance_inputs.py`, `hub_core/research_ops_enforcement.py`, + `hub_core/publication_readiness.py`, and + `hub_core/mcp/tools/render_project.py`. +- Extend `project_config_template.yaml` and + `hub_core/templates/project_config_template.yaml` together after the resolver + contract is locked. Preserve existing module defaults and `false` opt-outs. +- Teach `hub_core/config_parser.py` validation and migration to recognize the + additive workflow/policy fields. Unknown future policy versions fail closed + for execution/promotion but remain inspectable. + +**Likely tests:** existing `tests/test_render_evidence.py`, +`tests/test_render_project_policy_integration.py`, +`tests/test_wp2_integrity_readiness.py`, `tests/test_evidence_contract.py`, +`tests/test_research_ops_render_gates.py`, `tests/test_config_placeholders.py`, +and new `tests/test_policy_resolution.py` / `tests/test_workflow_intent.py`. + +**Exit:** all call paths emit equivalent policy decisions for equivalent input; +source and opt-out provenance are stable; exploration cannot be marked +promotable; no existing render/default behavior changes without an explicit +compatibility test. + +### Phase 3 — Pure promotion-gate evaluator + +**Goal:** decide eligibility deterministically without performing promotion. + +**Current status:** pure domain evaluator/receipt foundation implemented; the +Phase 4 admission integration is implemented. A CLI/MCP pure evaluator route +remains pending. + +- Implemented `hub_core/promotion_gate.py` and + `hub_core/promotion_gate_receipt.py` as the pure evaluator/receipt foundation. +- Consume, but do not reimplement, existing `hub_core/result_promotion.py` + eligibility facts, `hub_core/publication_evidence.py` normalization, + `hub_core/publication_readiness.py` state/finding evidence, + `hub_core/claim_inventory.py`, `hub_core/calculation_evidence.py`, and + `hub_core/durable_receipt.py`. +- Gate code ownership and stable precedence are specified. The evaluator returns a + deterministic report/receipt candidate and has no filesystem mutation API. +- Add a read-only evaluation route alongside existing publication readiness, + likely in `hub_core/publication_cli.py`, `orchestrator.py`, and + `hub_core/mcp/tools/readiness_tools.py`, once its public schema is reviewed. + +**Likely tests:** new `tests/test_promotion_gate.py`; existing +`tests/test_publication_readiness.py`, `tests/test_publication_cli.py`, +`tests/test_mcp_publication_readiness.py`, `tests/test_result_promotion_integration.py`, +and `tests/test_calculation_evidence_lineage.py`. + +**Exit:** the exact same inputs yield byte-stable gate reports through domain, +CLI, and MCP; invalid review/lineage/policy evidence blocks; `needs_review` +never becomes `eligible` without a valid required receipt. + +### Phase 4 — Narrow signoff and promotion integration + +**Goal:** expose an explicitly write-gated review-recording path and interpose +the gate before the existing durable promotion primitive. + +**Current status:** storage-only `hub_core/review_recording.py` and the +optional `figops.record_human_review` write-gated MCP path are implemented. +The MCP writer resolves `figure_id` through trusted project config, binds the +review subject `project_id`/`artifact_id` to trusted `project.name`/`figure_id` +opaque IDs, and leaves the default read-only 14+13 compatibility discovery +unchanged. The optional `hub_core/result_promotion.py` admission guard binds +promotion-gate subject identity to the same trusted opaque IDs, recomputes +`subject_digest` with the canonical default scope before publication, freezes +the validated promotion-gate receipt before native promotion, and handles native +promotion failure by removing the exact owned receipt where identity-bound +deletion is supported while fail-closed unsupported POSIX deletion retains the +canonical receipt for manual review. Phase 5 migration/release governance +remains pending. + +- Implemented storage-only `hub_core/review_recording.py` foundation and + optional `figops.record_human_review` MCP path create append-only review + records below the declared evidence role using the same contained/no-clobber + standards as durable results. +- Implemented the optional `hub_core/result_promotion.py` promotion-gate + admission guard at the admission boundary; it binds the gate subject + `project_id`/`artifact_id` to trusted `project.name`/`figure_id` opaque IDs + and recomputes `subject_digest` with the canonical default scope before any + publication write. Keep + `hub_core/durable_promotion.py` and `hub_core/atomic_no_clobber.py` as the + only byte-publication primitives. +- Implemented strict trusted config figure binding for the MCP writer: caller + `figure_id` must resolve to exactly one trusted configured figure, and review + subject identities must match the trusted project/figure opaque IDs. +- Implemented the MCP writer as an additive optional write path outside frozen + discovery profiles. Write-disabled/read-only behavior stays default, and the + frozen 14 canonical + 13 legacy compatibility profile remains unchanged. +- Implemented promotion-gate receipt pre-freeze before native promotion. If + native promotion fails, the exact owned receipt is removed where + identity-bound deletion is supported; unsupported POSIX deletion fails closed + and retains the canonical receipt for manual review; no competing destination + is overwritten and no review decision is backfilled. + +**Likely tests:** existing `tests/test_durable_promotion.py`, +`tests/test_result_promotion_integration.py`, `tests/test_mcp_write_gating.py` +(if present at implementation time; otherwise add it), `tests/test_workflow_security.py`, +`tests/test_symlink_policy.py`, `tests/test_structure_path_security.py`, and +new end-to-end lifecycle fixtures. + +**Exit:** write-disabled MCP cannot record signoff or promote; a valid review +for a different hash cannot promote; concurrent destinations preserve the race +winner; runtime deletion leaves review and gate receipts verifiable; existing +non-review promotion behavior is unchanged until a policy explicitly selects +the new gate. + +### Phase 5 — Migration, release governance, and operational dogfood + +**Goal:** make the new lifecycle trustworthy in supported workflows without +making normal development CI perform expensive visual work. + +**Current status:** pending; migration/release governance is not yet complete. + +- Publish migration examples for active v1.1 projects and a read-only legacy + explanation. Add deprecation warnings only after compatibility evidence. +- Update generated tool references through the live registry process; preserve + v2/compatibility surface counts unless an explicit release decision changes + them. +- Extend `.github/workflows/ci.yml` with deterministic receipt/gate tests in + existing gating jobs. Keep full render-pack/model visual dogfood manually + dispatched, local, or path-filtered as [the roadmap](../ROADMAP.md) requires. +- Extend `tests/test_release_discipline.py`, `tests/test_public_release_check.py`, + and packaging/release decision records with a gate that prevents a release + from claiming signoff-gated promotion before lifecycle and platform witnesses + exist. + +**Exit:** migration and downgrade behavior are documented and tested; CI runs +the lifecycle adversarial matrix on supported platforms; release review has an +exact-commit, signed/attested human decision record where the selected release +policy requires one. + +## 8. Compatibility and migration rules + +1. The existing public evidence schemas, readiness states, role contract, + `manual_review_needed`, `promotion_eligible`, CLI commands, and frozen MCP + aliases remain readable. New fields and tools are additive during the first + release that contains this work. +2. Existing `publication_readiness/1` retains exactly `blocked`, + `needs_revision`, and `needs_review`. The promotion-gate state machine is a + separate contract; it does not redefine readiness. +3. Existing `result_promotion` callers retain their current machine eligibility + behavior until a selected policy explicitly requires the new gate. A release + must document when the default changes, provide a migration example, and + preserve an explicit compatibility selection for at least two minor releases + unless a security issue requires faster removal. +4. Existing research-ops `false` opt-outs remain honored only for their current + scoped rules. Migration must not broaden them into a signoff or invariant + bypass. The resolved policy records exactly why each default or opt-out was + used. +5. Legacy projects remain read-only/disabled as specified in the 2026-07-15 + SSOT. No automatic folder move, receipt backfill, or retrospective approval + occurs. +6. Receipt schemas are versioned and migratable for reading. A migration may + normalize/annotate legacy data but cannot invent a reviewer, signature, + affirmative decision, expiry, or missing subject binding. + +## 9. Non-goals + +- Automatic scientific approval, authorship attribution, coauthor consent, + IRB/ethics determination, legal review, or publisher acceptance. +- An arbitrary-code API, a broad external identity/SSO integration, a hosted + approval service, blockchain/notarization, or remote approval synchronization + in the first implementation. +- Replacing the `figops-project-v1.1` structure contract, external runtime + boundary, native no-replace promotion primitive, durable lineage receipt, or + current v2/legacy MCP compatibility policy. +- Treating an LLM visual inspection, a model score, a preview read, or a + generated narrative as a human signoff. +- Silently changing scientific/visual policy defaults, mutating historical + results, moving legacy projects, or adding a mandatory cloud provider/DVC. +- Running costly render packs, live-model evaluation, or external publication + actions as an automatic result of ordinary source changes. + +## 10. Acceptance criteria and release gates + +### 10.1 Product acceptance + +- A closed review receipt binds its decision to exact durable artifact, + lineage/evidence/policy subject digests and fails verification on any mismatch. +- Unknown fields, duplicate keys, bad encoding, path/secret leaks, non-finite + values, invalid hash/ID, stale receipts, replay, revoked/superseded receipt, + unclosed concern, unauthorized reviewer role, and invalid waiver fail closed. +- A policy resolver emits one canonical policy-set digest and explains every + applied default, inheritance, opt-out, and exception; immutable invariants + cannot be disabled. +- Exploratory renders are visibly non-promotable. Execution candidates require + complete current evidence before a review receipt can satisfy promotion. +- Readiness remains non-approving. An affirmative human receipt is insufficient + if automatic invariants or required policy gates fail. +- The promotion gate evaluates identically through pure domain, CLI, and MCP + surfaces. It does not mutate state; only the dedicated write-gated integration + records receipts or calls promotion. +- A successful promotion uses the existing native no-replace path, writes + durable lineage/review/gate receipts, and remains verifiable after runtime + deletion. All failed paths preserve existing artifacts and do not create a + false promoted state. +- Legacy and compatibility behaviors retain their documented read-only/default + semantics and receive no invented approval. + +### 10.2 CI and release gates + +Before any release exposes signoff-gated promotion, all are required on the +exact release commit: + +1. Locked full pytest and Ruff are green, including the current macOS path + identity, Windows containment/symlink zero-skip, and actual-R gates required + by the 2026-07-15 SSOT. +2. Receipt and promotion-gate fixture matrices pass on Windows and macOS, with + zero skipped security/lifecycle cases. At minimum they cover tampering, + stale/revoked/superseded state, role/authority mismatch, policy drift, + cross-project/hash replay, write-disabled MCP, and no-replace race behavior. +3. Domain/CLI/MCP parity and deterministic canonical-byte/golden-report tests + pass; generated schemas and tool documentation show no unreviewed drift. +4. Compatibility and legacy regression suites pass. A release note identifies + every new default, migration step, and remaining policy-limited capability. +5. One operational dogfood run records a real execution candidate, bounded + review evidence, an explicit human decision, and a successful or safely + denied promotion. It is evidence of workflow operation, not a scientific or + publisher acceptance claim. +6. Required repository, legal, and release approvals are recorded separately + from figure-review receipts. The release process rechecks the technical gates + on the exact commit and follows the `v0.20.0` corrective baseline's release + discipline until its successor release policy is formally adopted. + +## 11. Decision log and review questions + +The following decisions are fixed for implementation unless a later dated SSOT +explicitly changes them: + +- Automatic quality evidence is never human approval. +- Review is bound to immutable subject digests, not a filename, job ID, or + mutable manifest path. +- Human approval records a limited decision scope; it does not claim universal + scientific or publication truth. +- Kernel invariants are not waivable. Policy exceptions are narrow, attributable, + expiring, and cannot convert missing evidence into a pass. +- Legacy artifacts are not retroactively approved. +- The existing durable promotion primitive remains the sole mechanism that can + publish bytes into the protected result/publication destination. + +Implementation must resolve the following before Phase 4 through an approved +Phase 0/1 receipt that explicitly points back to this SSOT: whether the first +supported human identity is local attestation only or a specific verified +identity provider; what review scopes/roles each shipped policy recognizes; the +default receipt expiry; and the operational owner of a local revocation index. +Until resolved, the system must expose read-only evaluation only and fail closed +for policies requiring verified signoff. diff --git a/docs/tools-compatibility.md b/docs/tools-compatibility.md index 83e3a1e..bf0d92b 100644 --- a/docs/tools-compatibility.md +++ b/docs/tools-compatibility.md @@ -4905,6 +4905,94 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "output_path": { "type": "string" }, + "policy_context": { + "additionalProperties": false, + "properties": { + "policy_set": { + "additionalProperties": false, + "properties": { + "parameters": { + "maxProperties": 64, + "type": "object" + }, + "schema_version": { + "const": "figops-resolved-policy-set/1", + "type": "string" + } + }, + "required": [ + "schema_version", + "parameters" + ], + "type": "object" + }, + "policy_set_sha256": { + "pattern": "^[0-9a-fA-F]{64}$", + "type": "string" + }, + "render_policy": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "parameters": { + "maxProperties": 32, + "type": "object" + }, + "source": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "version", + "source", + "parameters" + ], + "type": "object" + }, + "schema_version": { + "const": "figops-render-policy-context/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit-render-policy", + "compatibility-default", + "v2-default" + ], + "type": "string" + }, + "validation_source": { + "enum": [ + "explicit-validation-target", + "compatibility-target-inference", + "none" + ], + "type": "string" + }, + "validation_target": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "source", + "validation_source", + "policy_set_sha256", + "policy_set", + "render_policy", + "validation_target" + ], + "type": "object" + }, "preview_resources": { "items": { "maxLength": 256, @@ -4979,6 +5067,145 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "type": "string" }, "type": "array" + }, + "workflow_intent": { + "additionalProperties": false, + "properties": { + "execution_allowed": { + "type": "boolean" + }, + "fail_closed": { + "type": "boolean" + }, + "intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "issues": { + "items": { + "type": "string" + }, + "maxItems": 64, + "type": "array" + }, + "legacy": { + "type": "boolean" + }, + "promotable": { + "type": "boolean" + }, + "promotion_allowed": { + "type": "boolean" + }, + "provenance": { + "additionalProperties": false, + "properties": { + "active": { + "type": "boolean" + }, + "config_source": { + "type": "string" + }, + "project_status": { + "type": "string" + }, + "requested_intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "requested_source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + }, + "step": { + "type": [ + "string", + "null" + ] + }, + "tool_name": { + "type": "string" + } + }, + "required": [ + "active", + "step", + "tool_name", + "requested_intent", + "requested_source", + "project_status", + "config_source" + ], + "type": "object" + }, + "read_only": { + "type": "boolean" + }, + "schema_version": { + "const": "figops-workflow-intent/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "intent", + "source", + "provenance", + "fail_closed", + "legacy", + "execution_allowed", + "promotion_allowed", + "read_only", + "promotable", + "issues" + ], + "type": "object" } }, "type": "object" diff --git a/docs/tools-v2.md b/docs/tools-v2.md index 565acd2..de9b506 100644 --- a/docs/tools-v2.md +++ b/docs/tools-v2.md @@ -951,6 +951,94 @@ Render one configured project-local .py or .R figure; code and command strings a "manual_review_needed": { "type": "boolean" }, + "policy_context": { + "additionalProperties": false, + "properties": { + "policy_set": { + "additionalProperties": false, + "properties": { + "parameters": { + "maxProperties": 64, + "type": "object" + }, + "schema_version": { + "const": "figops-resolved-policy-set/1", + "type": "string" + } + }, + "required": [ + "schema_version", + "parameters" + ], + "type": "object" + }, + "policy_set_sha256": { + "pattern": "^[0-9a-fA-F]{64}$", + "type": "string" + }, + "render_policy": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "parameters": { + "maxProperties": 32, + "type": "object" + }, + "source": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "version", + "source", + "parameters" + ], + "type": "object" + }, + "schema_version": { + "const": "figops-render-policy-context/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit-render-policy", + "compatibility-default", + "v2-default" + ], + "type": "string" + }, + "validation_source": { + "enum": [ + "explicit-validation-target", + "compatibility-target-inference", + "none" + ], + "type": "string" + }, + "validation_target": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "source", + "validation_source", + "policy_set_sha256", + "policy_set", + "render_policy", + "validation_target" + ], + "type": "object" + }, "preview_uri": { "type": [ "string", @@ -982,6 +1070,145 @@ Render one configured project-local .py or .R figure; code and command strings a "type": "string" }, "type": "array" + }, + "workflow_intent": { + "additionalProperties": false, + "properties": { + "execution_allowed": { + "type": "boolean" + }, + "fail_closed": { + "type": "boolean" + }, + "intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "issues": { + "items": { + "type": "string" + }, + "maxItems": 64, + "type": "array" + }, + "legacy": { + "type": "boolean" + }, + "promotable": { + "type": "boolean" + }, + "promotion_allowed": { + "type": "boolean" + }, + "provenance": { + "additionalProperties": false, + "properties": { + "active": { + "type": "boolean" + }, + "config_source": { + "type": "string" + }, + "project_status": { + "type": "string" + }, + "requested_intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "requested_source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + }, + "step": { + "type": [ + "string", + "null" + ] + }, + "tool_name": { + "type": "string" + } + }, + "required": [ + "active", + "step", + "tool_name", + "requested_intent", + "requested_source", + "project_status", + "config_source" + ], + "type": "object" + }, + "read_only": { + "type": "boolean" + }, + "schema_version": { + "const": "figops-workflow-intent/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "intent", + "source", + "provenance", + "fail_closed", + "legacy", + "execution_allowed", + "promotion_allowed", + "read_only", + "promotable", + "issues" + ], + "type": "object" } }, "type": "object" diff --git a/docs/tools.md b/docs/tools.md index c7e61d9..ba6a7e8 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -4905,6 +4905,94 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "output_path": { "type": "string" }, + "policy_context": { + "additionalProperties": false, + "properties": { + "policy_set": { + "additionalProperties": false, + "properties": { + "parameters": { + "maxProperties": 64, + "type": "object" + }, + "schema_version": { + "const": "figops-resolved-policy-set/1", + "type": "string" + } + }, + "required": [ + "schema_version", + "parameters" + ], + "type": "object" + }, + "policy_set_sha256": { + "pattern": "^[0-9a-fA-F]{64}$", + "type": "string" + }, + "render_policy": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "parameters": { + "maxProperties": 32, + "type": "object" + }, + "source": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "version", + "source", + "parameters" + ], + "type": "object" + }, + "schema_version": { + "const": "figops-render-policy-context/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit-render-policy", + "compatibility-default", + "v2-default" + ], + "type": "string" + }, + "validation_source": { + "enum": [ + "explicit-validation-target", + "compatibility-target-inference", + "none" + ], + "type": "string" + }, + "validation_target": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "source", + "validation_source", + "policy_set_sha256", + "policy_set", + "render_policy", + "validation_target" + ], + "type": "object" + }, "preview_resources": { "items": { "maxLength": 256, @@ -4979,6 +5067,145 @@ Render one configured project figure in an isolated runtime-root MCP job workspa "type": "string" }, "type": "array" + }, + "workflow_intent": { + "additionalProperties": false, + "properties": { + "execution_allowed": { + "type": "boolean" + }, + "fail_closed": { + "type": "boolean" + }, + "intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "issues": { + "items": { + "type": "string" + }, + "maxItems": 64, + "type": "array" + }, + "legacy": { + "type": "boolean" + }, + "promotable": { + "type": "boolean" + }, + "promotion_allowed": { + "type": "boolean" + }, + "provenance": { + "additionalProperties": false, + "properties": { + "active": { + "type": "boolean" + }, + "config_source": { + "type": "string" + }, + "project_status": { + "type": "string" + }, + "requested_intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "requested_source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + }, + "step": { + "type": [ + "string", + "null" + ] + }, + "tool_name": { + "type": "string" + } + }, + "required": [ + "active", + "step", + "tool_name", + "requested_intent", + "requested_source", + "project_status", + "config_source" + ], + "type": "object" + }, + "read_only": { + "type": "boolean" + }, + "schema_version": { + "const": "figops-workflow-intent/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "intent", + "source", + "provenance", + "fail_closed", + "legacy", + "execution_allowed", + "promotion_allowed", + "read_only", + "promotable", + "issues" + ], + "type": "object" } }, "type": "object" @@ -6440,6 +6667,94 @@ Render one configured project-local .py or .R figure; code and command strings a "manual_review_needed": { "type": "boolean" }, + "policy_context": { + "additionalProperties": false, + "properties": { + "policy_set": { + "additionalProperties": false, + "properties": { + "parameters": { + "maxProperties": 64, + "type": "object" + }, + "schema_version": { + "const": "figops-resolved-policy-set/1", + "type": "string" + } + }, + "required": [ + "schema_version", + "parameters" + ], + "type": "object" + }, + "policy_set_sha256": { + "pattern": "^[0-9a-fA-F]{64}$", + "type": "string" + }, + "render_policy": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "parameters": { + "maxProperties": 32, + "type": "object" + }, + "source": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "version", + "source", + "parameters" + ], + "type": "object" + }, + "schema_version": { + "const": "figops-render-policy-context/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit-render-policy", + "compatibility-default", + "v2-default" + ], + "type": "string" + }, + "validation_source": { + "enum": [ + "explicit-validation-target", + "compatibility-target-inference", + "none" + ], + "type": "string" + }, + "validation_target": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "source", + "validation_source", + "policy_set_sha256", + "policy_set", + "render_policy", + "validation_target" + ], + "type": "object" + }, "preview_uri": { "type": [ "string", @@ -6471,6 +6786,145 @@ Render one configured project-local .py or .R figure; code and command strings a "type": "string" }, "type": "array" + }, + "workflow_intent": { + "additionalProperties": false, + "properties": { + "execution_allowed": { + "type": "boolean" + }, + "fail_closed": { + "type": "boolean" + }, + "intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "issues": { + "items": { + "type": "string" + }, + "maxItems": 64, + "type": "array" + }, + "legacy": { + "type": "boolean" + }, + "promotable": { + "type": "boolean" + }, + "promotion_allowed": { + "type": "boolean" + }, + "provenance": { + "additionalProperties": false, + "properties": { + "active": { + "type": "boolean" + }, + "config_source": { + "type": "string" + }, + "project_status": { + "type": "string" + }, + "requested_intent": { + "enum": [ + "exploration", + "execution", + "review", + "promotion", + null + ], + "type": [ + "string", + "null" + ] + }, + "requested_source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + }, + "step": { + "type": [ + "string", + "null" + ] + }, + "tool_name": { + "type": "string" + } + }, + "required": [ + "active", + "step", + "tool_name", + "requested_intent", + "requested_source", + "project_status", + "config_source" + ], + "type": "object" + }, + "read_only": { + "type": "boolean" + }, + "schema_version": { + "const": "figops-workflow-intent/1", + "type": "string" + }, + "source": { + "enum": [ + "explicit", + "orchestrator", + "mcp", + "direct_csv", + "read_only", + "readiness", + "legacy", + null + ], + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "schema_version", + "intent", + "source", + "provenance", + "fail_closed", + "legacy", + "execution_allowed", + "promotion_allowed", + "read_only", + "promotable", + "issues" + ], + "type": "object" } }, "type": "object" diff --git a/graphhub_mcp_server.py b/graphhub_mcp_server.py index 6887a1b..0cf9cae 100644 --- a/graphhub_mcp_server.py +++ b/graphhub_mcp_server.py @@ -3,11 +3,48 @@ import argparse import json +from typing import TYPE_CHECKING, Any +from hub_core.approval_authority import ApprovalAuthorityRoot from hub_core.doctor import format_doctor_report, run_doctor from hub_core.logging import configure_logging from hub_core.mcp.config import McpServerConfig +if TYPE_CHECKING: + from hub_core.mcp import FigOpsMCPServer + + +def _build_production_server( + config: McpServerConfig, + *, + host_authority_root: ApprovalAuthorityRoot | None = None, + **server_kwargs: Any, +) -> "FigOpsMCPServer": + from hub_core.mcp import FigOpsMCPServer + + if host_authority_root is not None and not isinstance(host_authority_root, ApprovalAuthorityRoot): + raise TypeError("host_authority_root must be an ApprovalAuthorityRoot.") + trusted_root = host_authority_root if host_authority_root is not None else ApprovalAuthorityRoot() + return FigOpsMCPServer( + config=config, + require_host_approval=True, + host_authority_root=trusted_root, + **server_kwargs, + ) + + +def build_trusted_figops_mcp_server( + *, + config: McpServerConfig, + host_authority_root: ApprovalAuthorityRoot | None = None, + **server_kwargs: Any, +) -> "FigOpsMCPServer": + return _build_production_server( + config, + host_authority_root=host_authority_root, + **server_kwargs, + ) + def _run_smoke(config: McpServerConfig) -> int: doctor_report = run_doctor(config) @@ -25,9 +62,7 @@ def _run_smoke(config: McpServerConfig) -> int: print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) return 1 - from hub_core.mcp import FigOpsMCPServer - - server = FigOpsMCPServer(config=config) + server = _build_production_server(config) health = server.call_tool("figops.health", {})["structuredContent"] styles = server.call_tool("figops.list_styles", {})["structuredContent"] payload = { @@ -77,9 +112,9 @@ def main() -> int: else: print(format_doctor_report(report)) return 0 if report["ready"] else 1 - from hub_core.mcp import FigOpsMCPServer, run_stdio_server + from hub_core.mcp import run_stdio_server - return run_stdio_server(FigOpsMCPServer(config=config, require_initialize=True)) + return run_stdio_server(_build_production_server(config, require_initialize=True)) if __name__ == "__main__": diff --git a/hub_core/approval_authority.py b/hub_core/approval_authority.py new file mode 100644 index 0000000..961f607 --- /dev/null +++ b/hub_core/approval_authority.py @@ -0,0 +1,45 @@ +"""Public compatibility facade for process-local approval authority. + +The implementation is split between two focused modules: + +* :mod:`approval_authority_codec` contains canonical JSON, digest, timestamp, + and immutable binding validation. +* :mod:`approval_authority_lifecycle` contains the host-owned trust root, + issuance/revocation/supersession, and fail-closed verification. + +This facade intentionally re-exports the established public API. Consumers +can continue importing from ``hub_core.approval_authority`` without changing +the class identities used by the trust boundary. +""" + +from .approval_authority_codec import ( + ApprovalAuthorityError, + ApprovalBinding, + approval_binding_digest, + canonical_approval_binding_bytes, + canonical_json_bytes, + canonical_plan_digest, +) +from .approval_authority_lifecycle import ( + ApprovalAuthority, + ApprovalAuthorityRoot, + ApprovalRecord, + ApprovalVerificationResult, + verify_approval, + verify_approval_authority, +) + +__all__ = [ + "ApprovalAuthority", + "ApprovalAuthorityError", + "ApprovalAuthorityRoot", + "ApprovalBinding", + "ApprovalRecord", + "ApprovalVerificationResult", + "approval_binding_digest", + "canonical_approval_binding_bytes", + "canonical_json_bytes", + "canonical_plan_digest", + "verify_approval", + "verify_approval_authority", +] diff --git a/hub_core/approval_authority_codec.py b/hub_core/approval_authority_codec.py new file mode 100644 index 0000000..41d0c9a --- /dev/null +++ b/hub_core/approval_authority_codec.py @@ -0,0 +1,229 @@ +"""Canonical encoding and validation for approval authority records. + +The approval authority lifecycle lives in :mod:`approval_authority_lifecycle`. +This module owns the deterministic JSON representation and the immutable +binding that is signed by the process-local authority root. Keeping these +operations separate makes the security boundary easier to audit while +retaining the same exceptions and canonical digest behaviour as the public +``approval_authority`` module. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +from dataclasses import dataclass +from datetime import UTC, datetime +from types import MappingProxyType +from typing import Any, Mapping, NoReturn + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_RECEIPT_ID_RE = re.compile(r"^approval:sha256:[0-9a-f]{64}$") +_UTC_TIMESTAMP_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") + + +class ApprovalAuthorityError(ValueError): + """Raised when an approval authority contract cannot be constructed.""" + + +def _fail(message: str) -> NoReturn: + raise ApprovalAuthorityError(f"approval authority {message}") + + +def _jsonable(value: Any) -> Any: + """Convert immutable values back to plain deterministic JSON values.""" + + if isinstance(value, Mapping): + result: dict[str, Any] = {} + for key, child in value.items(): + if not isinstance(key, str): + _fail("JSON object keys must be strings") + result[key] = _jsonable(child) + return result + if isinstance(value, tuple): + return [_jsonable(child) for child in value] + if isinstance(value, list): + return [_jsonable(child) for child in value] + if value is None or isinstance(value, (str, bool, int)): + return value + if isinstance(value, float): + if not math.isfinite(value): + _fail("JSON values must be finite") + return value + _fail(f"JSON value has unsupported type {type(value).__name__}") + + +def _freeze_json(value: Any) -> Any: + """Deep-freeze JSON data so records cannot be changed through aliases.""" + + if isinstance(value, Mapping): + result = {key: _freeze_json(child) for key, child in value.items()} + if any(not isinstance(key, str) for key in result): + _fail("JSON object keys must be strings") + return MappingProxyType(result) + if isinstance(value, (list, tuple)): + return tuple(_freeze_json(child) for child in value) + if value is None or isinstance(value, (str, bool, int)): + return value + if isinstance(value, float): + if not math.isfinite(value): + _fail("JSON values must be finite") + return value + _fail(f"JSON value has unsupported type {type(value).__name__}") + + +def canonical_json_bytes(value: Any) -> bytes: + """Return deterministic UTF-8 JSON bytes for a JSON-compatible value.""" + + try: + return json.dumps( + _jsonable(value), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + except (TypeError, ValueError, OverflowError, RecursionError) as exc: + raise ApprovalAuthorityError(f"JSON canonicalization failed: {exc}") from exc + + +def _sha256_bytes(value: Any) -> str: + return hashlib.sha256(canonical_json_bytes(value)).hexdigest() + + +def _valid_digest(value: Any, field: str) -> str: + if not isinstance(value, str) or _SHA256_RE.fullmatch(value) is None: + _fail(f"{field} must be a lowercase SHA-256 digest") + return value + + +def _text(value: Any, field: str, *, max_length: int = 512) -> str: + if not isinstance(value, str) or not value or value != value.strip() or len(value) > max_length: + _fail(f"{field} must be a non-empty canonical string") + if any(ord(char) < 0x20 or ord(char) == 0x7F for char in value): + _fail(f"{field} may not contain control characters") + return value + + +def _timestamp(value: Any, field: str) -> str: + if isinstance(value, datetime): + if value.tzinfo is None or value.utcoffset() is None: + _fail(f"{field} must be timezone-aware") + value = value.astimezone(UTC).replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%SZ") + if not isinstance(value, str) or _UTC_TIMESTAMP_RE.fullmatch(value) is None: + _fail(f"{field} must be an RFC 3339 UTC timestamp with seconds precision and Z suffix") + try: + datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) + except ValueError as exc: + raise ApprovalAuthorityError(f"approval authority {field} must be a real timestamp") from exc + return value + + +def _parse_timestamp(value: str) -> datetime: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) + + +def canonical_plan_digest(plan: Mapping[str, Any]) -> str: + """Hash a plan excluding its self-referential ``digest`` fields. + + Structure plans use ``digest``; callers using ``plan_digest`` receive the + same treatment. A declared digest, when present, must match this value. + """ + + if not isinstance(plan, Mapping): + _fail("plan must be a mapping") + payload = {key: value for key, value in plan.items() if key not in {"digest", "plan_digest"}} + return _sha256_bytes(payload) + + +def _checked_plan_digest(plan: Mapping[str, Any]) -> str: + digest = canonical_plan_digest(plan) + for field in ("digest", "plan_digest"): + if field in plan and plan[field] != digest: + _fail(f"plan {field} is stale or invalid") + return digest + + +def _field_digest(plan: Mapping[str, Any], *fields: str) -> str: + for field in fields: + if field in plan: + return _sha256_bytes(plan[field]) + # Omission is distinct from an explicitly reviewed empty list. + return _sha256_bytes({"__missing_field__": fields[0]}) + + +def _identity(plan: Mapping[str, Any], field: str, *, allow_none: bool = False) -> Any: + value = plan.get(field) + if value is None and allow_none: + return None + if value is None: + _fail(f"plan {field} is required") + return _freeze_json(value) + + +@dataclass(frozen=True, slots=True) +class ApprovalBinding: + """Immutable canonical binding for one host-issued approval.""" + + plan_digest: str + project_root_identity: Any + config_sha256: str | None + config_identity: Any + reviewed_entries_digest: str + approved_mappings_digest: str + config_diff_digest: str + hardcoded_unresolved_references_digest: str + unresolved_proposals_digest: str + reviewer_identity: str + reviewer_role: str + issued_at: str + expires_at: str + current: bool = True + revoked: bool = False + superseded: bool = False + superseded_by: str | None = None + supersedes: str | None = None + + def canonical_payload(self) -> dict[str, Any]: + return { + "plan_digest": self.plan_digest, + "project_root_identity": _jsonable(self.project_root_identity), + "config_sha256": self.config_sha256, + "config_identity": _jsonable(self.config_identity), + "reviewed_entries_digest": self.reviewed_entries_digest, + "approved_mappings_digest": self.approved_mappings_digest, + "config_diff_digest": self.config_diff_digest, + "hardcoded_unresolved_references_digest": self.hardcoded_unresolved_references_digest, + "unresolved_proposals_digest": self.unresolved_proposals_digest, + "reviewer_identity": self.reviewer_identity, + "reviewer_role": self.reviewer_role, + "issued_at": self.issued_at, + "expires_at": self.expires_at, + "current": self.current, + "revoked": self.revoked, + "superseded": self.superseded, + "superseded_by": self.superseded_by, + "supersedes": self.supersedes, + } + + +def canonical_approval_binding_bytes(binding: ApprovalBinding) -> bytes: + if not isinstance(binding, ApprovalBinding): + _fail("binding must be an ApprovalBinding") + return canonical_json_bytes(binding.canonical_payload()) + + +def approval_binding_digest(binding: ApprovalBinding) -> str: + return hashlib.sha256(canonical_approval_binding_bytes(binding)).hexdigest() + + +__all__ = [ + "ApprovalAuthorityError", + "ApprovalBinding", + "approval_binding_digest", + "canonical_approval_binding_bytes", + "canonical_json_bytes", + "canonical_plan_digest", +] diff --git a/hub_core/approval_authority_lifecycle.py b/hub_core/approval_authority_lifecycle.py new file mode 100644 index 0000000..ecf275b --- /dev/null +++ b/hub_core/approval_authority_lifecycle.py @@ -0,0 +1,310 @@ +"""Issuance, lifecycle, and verification for process-local approvals. + +The codec module owns canonical data and validation primitives. This module +owns the mutable in-process index and the exact-root checks that make an +approval authoritative. Records retain the root object itself as an +identity-bearing capability; serialising or copying a record never grants +authority to a different root. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any, Mapping + +from .approval_authority_codec import ( + _RECEIPT_ID_RE, + ApprovalAuthorityError, + ApprovalBinding, + _checked_plan_digest, + _field_digest, + _freeze_json, + _identity, + _parse_timestamp, + _text, + _timestamp, + _valid_digest, + approval_binding_digest, + canonical_plan_digest, +) + + +@dataclass(frozen=True, slots=True, init=False) +class ApprovalRecord: + """Immutable approval record minted by an authority root.""" + + receipt_id: str + binding: ApprovalBinding + _authority_owner: object + + def __init__(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover - public construction is forbidden + raise TypeError("ApprovalRecord instances must be issued by ApprovalAuthorityRoot") + + @classmethod + def _mint(cls, owner: object, receipt_id: str, binding: ApprovalBinding) -> "ApprovalRecord": + record = object.__new__(cls) + object.__setattr__(record, "receipt_id", receipt_id) + object.__setattr__(record, "binding", binding) + object.__setattr__(record, "_authority_owner", owner) + return record + + @property + def plan_digest(self) -> str: + return self.binding.plan_digest + + @property + def current(self) -> bool: + return self.binding.current + + @property + def revoked(self) -> bool: + return self.binding.revoked + + @property + def superseded(self) -> bool: + return self.binding.superseded + + @property + def canonical_digest(self) -> str: + return approval_binding_digest(self.binding) + + +@dataclass(frozen=True, slots=True) +class ApprovalVerificationResult: + """Fail-closed result returned by :func:`verify_approval_authority`.""" + + valid: bool + reason: str + receipt_id: str | None = None + record: ApprovalRecord | None = None + + def __bool__(self) -> bool: + return self.valid + + +class _AuthorityState: + """Mutable lifecycle index entry; never exposed as an authority value.""" + + __slots__ = ("record", "current", "revoked", "superseded_by") + + def __init__(self, record: ApprovalRecord) -> None: + self.record = record + self.current = True + self.revoked = False + self.superseded_by: str | None = None + + +class ApprovalAuthorityRoot: + """Host-owned, process-local trust root and approval index.""" + + __slots__ = ("_records",) + + def __init__(self) -> None: + self._records: dict[str, _AuthorityState] = {} + + def __copy__(self) -> "ApprovalAuthorityRoot": # pragma: no cover - defensive boundary + raise TypeError("ApprovalAuthorityRoot cannot be copied") + + def __deepcopy__(self, memo: dict[int, Any]) -> "ApprovalAuthorityRoot": # pragma: no cover + raise TypeError("ApprovalAuthorityRoot cannot be copied") + + def issue_approval( + self, + plan: Mapping[str, Any], + *, + reviewer_identity: str, + reviewer_role: str, + issued_at: str | datetime, + expires_at: str | datetime, + supersedes: str | None = None, + ) -> ApprovalRecord: + plan_digest = _checked_plan_digest(plan) + project_root_identity = _identity(plan, "project_root_identity") + config_identity = _identity(plan, "config_identity", allow_none=True) + config_sha256 = plan.get("config_sha256") + if config_sha256 is not None: + config_sha256 = _valid_digest(config_sha256, "plan config_sha256") + reviewer_identity = _text(reviewer_identity, "reviewer_identity") + reviewer_role = _text(reviewer_role, "reviewer_role", max_length=128) + issued_at = _timestamp(issued_at, "issued_at") + expires_at = _timestamp(expires_at, "expires_at") + if _parse_timestamp(expires_at) <= _parse_timestamp(issued_at): + raise ApprovalAuthorityError("approval authority expires_at must be later than issued_at") + supersedes = None if supersedes is None else _text(supersedes, "supersedes", max_length=80) + if supersedes is not None: + if _RECEIPT_ID_RE.fullmatch(supersedes) is None: + raise ApprovalAuthorityError("approval authority supersedes must be an approval receipt id") + old = self._records.get(supersedes) + if old is None: + raise ApprovalAuthorityError("approval authority supersedes references an unknown receipt id") + if old.revoked: + raise ApprovalAuthorityError("approval authority a revoked approval cannot be superseded") + if not old.current: + raise ApprovalAuthorityError("approval authority only a current approval can be superseded") + + binding = ApprovalBinding( + plan_digest=plan_digest, + project_root_identity=project_root_identity, + config_sha256=config_sha256, + config_identity=config_identity, + reviewed_entries_digest=_field_digest(plan, "reviewed_entries", "entries"), + approved_mappings_digest=_field_digest(plan, "approved_mappings"), + config_diff_digest=_field_digest(plan, "config_diff"), + hardcoded_unresolved_references_digest=_field_digest(plan, "hardcoded_unresolved_references"), + unresolved_proposals_digest=_field_digest(plan, "unresolved_proposals"), + reviewer_identity=reviewer_identity, + reviewer_role=reviewer_role, + issued_at=issued_at, + expires_at=expires_at, + supersedes=supersedes, + ) + receipt_id = f"approval:sha256:{approval_binding_digest(binding)}" + if receipt_id in self._records: + raise ApprovalAuthorityError( + "approval authority an approval with the same immutable binding already exists" + ) + record = ApprovalRecord._mint(self, receipt_id, binding) + self._records[receipt_id] = _AuthorityState(record) + if supersedes is not None: + old = self._records[supersedes] + old.current = False + old.superseded_by = receipt_id + return record + + def issue(self, plan: Mapping[str, Any], **kwargs: Any) -> ApprovalRecord: + """Short alias for :meth:`issue_approval`.""" + + return self.issue_approval(plan, **kwargs) + + def revoke(self, receipt_id: str) -> None: + state = self._state_for(receipt_id) + if state.revoked: + return + state.revoked = True + state.current = False + + def revoke_approval(self, receipt_id: str) -> None: + self.revoke(receipt_id) + + def supersede(self, receipt_id: str, plan: Mapping[str, Any], **kwargs: Any) -> ApprovalRecord: + kwargs["supersedes"] = receipt_id + return self.issue_approval(plan, **kwargs) + + def supersede_approval(self, receipt_id: str, plan: Mapping[str, Any], **kwargs: Any) -> ApprovalRecord: + return self.supersede(receipt_id, plan, **kwargs) + + def get(self, receipt_id: str) -> ApprovalRecord | None: + state = self._records.get(receipt_id) + return None if state is None else state.record + + def lookup(self, receipt_id: str) -> ApprovalRecord | None: + """Return an immutable record snapshot without exposing the index.""" + + return self.get(receipt_id) + + def records(self) -> tuple[ApprovalRecord, ...]: + return tuple(state.record for state in self._records.values()) + + def _state_for(self, receipt_id: str) -> _AuthorityState: + if not isinstance(receipt_id, str) or _RECEIPT_ID_RE.fullmatch(receipt_id) is None: + raise ApprovalAuthorityError( + "approval authority receipt_id must be an approval:sha256: identifier" + ) + state = self._records.get(receipt_id) + if state is None: + raise ApprovalAuthorityError("approval authority unknown receipt_id") + return state + + def _lookup(self, receipt_id: str) -> _AuthorityState | None: + if not isinstance(receipt_id, str): + return None + return self._records.get(receipt_id) + + +def _result( + valid: bool, + reason: str, + receipt_id: str | None, + record: ApprovalRecord | None = None, +) -> ApprovalVerificationResult: + return ApprovalVerificationResult(valid, reason, receipt_id, record) + + +def verify_approval_authority( + plan: Mapping[str, Any], + receipt_id: str, + trusted_root: ApprovalAuthorityRoot | None = None, + now: str | datetime | None = None, +) -> ApprovalVerificationResult: + """Verify an approval against an exact host-owned root and current plan.""" + + if type(trusted_root) is not ApprovalAuthorityRoot: + return _result(False, "missing_or_untrusted_root", receipt_id if isinstance(receipt_id, str) else None) + if not isinstance(receipt_id, str) or _RECEIPT_ID_RE.fullmatch(receipt_id) is None: + return _result(False, "missing_or_invalid_receipt_id", receipt_id if isinstance(receipt_id, str) else None) + state = trusted_root._lookup(receipt_id) + if state is None: + return _result(False, "unknown_receipt_id", receipt_id) + record = state.record + if record._authority_owner is not trusted_root: + return _result(False, "untrusted_record", receipt_id, record) + if not isinstance(plan, Mapping): + return _result(False, "invalid_plan", receipt_id, record) + try: + plan_digest = canonical_plan_digest(plan) + if any(field in plan and plan[field] != plan_digest for field in ("digest", "plan_digest")): + return _result(False, "plan_digest_mismatch", receipt_id, record) + if plan_digest != record.binding.plan_digest: + return _result(False, "plan_digest_mismatch", receipt_id, record) + if _freeze_json(plan.get("project_root_identity")) != record.binding.project_root_identity: + return _result(False, "project_root_identity_mismatch", receipt_id, record) + config_sha256 = plan.get("config_sha256") + if config_sha256 is not None: + config_sha256 = _valid_digest(config_sha256, "plan config_sha256") + if config_sha256 != record.binding.config_sha256: + return _result(False, "config_sha256_mismatch", receipt_id, record) + if _freeze_json(plan.get("config_identity")) != record.binding.config_identity: + return _result(False, "config_identity_mismatch", receipt_id, record) + checks = ( + ("reviewed_entries_digest", _field_digest(plan, "reviewed_entries", "entries")), + ("approved_mappings_digest", _field_digest(plan, "approved_mappings")), + ("config_diff_digest", _field_digest(plan, "config_diff")), + ("hardcoded_unresolved_references_digest", _field_digest(plan, "hardcoded_unresolved_references")), + ("unresolved_proposals_digest", _field_digest(plan, "unresolved_proposals")), + ) + for field, actual in checks: + if actual != getattr(record.binding, field): + return _result(False, f"{field}_mismatch", receipt_id, record) + check_time = ( + _parse_timestamp(_timestamp(now, "now")) + if now is not None + else datetime.now(UTC).replace(microsecond=0) + ) + if check_time < _parse_timestamp(record.binding.issued_at): + return _result(False, "issued_in_future", receipt_id, record) + if check_time >= _parse_timestamp(record.binding.expires_at): + return _result(False, "expired", receipt_id, record) + except (ApprovalAuthorityError, TypeError, ValueError): + return _result(False, "invalid_plan", receipt_id, record) + if state.revoked or record.binding.revoked: + return _result(False, "revoked", receipt_id, record) + if state.superseded_by is not None or record.binding.superseded: + return _result(False, "superseded", receipt_id, record) + if not state.current or not record.binding.current: + return _result(False, "not_current", receipt_id, record) + return _result(True, "valid", receipt_id, record) + + +verify_approval = verify_approval_authority +ApprovalAuthority = ApprovalAuthorityRoot + + +__all__ = [ + "ApprovalAuthority", + "ApprovalAuthorityRoot", + "ApprovalRecord", + "ApprovalVerificationResult", + "verify_approval", + "verify_approval_authority", +] diff --git a/hub_core/artifact_policy_measurement.py b/hub_core/artifact_policy_measurement.py index b6eab9f..84ae9d9 100644 --- a/hub_core/artifact_policy_measurement.py +++ b/hub_core/artifact_policy_measurement.py @@ -19,10 +19,12 @@ from .journal_geometry_policy import geometry_minimum_results from .journal_specs import get_preflight_spec from .output_verification import verify_output_file +from .policy_resolution import PolicyResolutionError, resolve_policy_set MEASUREMENT_IMPLEMENTATION: Final = "figops-artifact-policy-measurement" MEASUREMENT_VERSION: Final = "3" RULE_VERSION: Final = "3" +RENDER_POLICY_CONTEXT_SCHEMA: Final = "figops-render-policy-context/1" _SHA256: Final = re.compile(r"^[0-9a-f]{64}$") _RASTER_SUFFIXES: Final = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp"} @@ -50,18 +52,78 @@ def resolve_render_policy_selection( the historical Nature effective default. """ - explicit = isinstance(style_policy, str) and bool(style_policy.strip()) - selected = str(style_policy).strip().lower() if explicit else "nature" if compatibility else "neutral" - if selected not in _RENDER_POLICIES: - raise ArtifactPolicyMeasurementError(f"unsupported render policy: {selected}") + return resolve_render_policy_context( + {"style_policy": style_policy} if style_policy is not None else {}, + compatibility=compatibility, + )["render_policy"] + + +def resolve_render_policy_context( + arguments: Mapping[str, Any] | None = None, + *, + target_format: str | None = None, + compatibility: bool | None = None, + policy_layers: Sequence[Mapping[str, Any]] | None = None, +) -> dict[str, Any]: + """Resolve render/validation policy through the canonical policy core. + + The returned context is additive for new callers: legacy callers may still + consume the singular ``render_policy`` and ``validation_target`` values, + while provenance-aware callers can bind to ``policy_set_sha256``. + """ + + if arguments is None: + arguments = {} + if not isinstance(arguments, Mapping): + raise ArtifactPolicyMeasurementError("render policy arguments must be a mapping") + v2_contract = arguments.get("v2_policy_contract") is True + compatibility_mode = (not v2_contract) if compatibility is None else bool(compatibility) + selected, source, raw_render_policy = _selected_render_policy( + arguments, + target_format=target_format, + compatibility=compatibility_mode, + ) + validation_target, validation_source = _selected_validation_target( + arguments, + target_format=target_format, + compatibility=compatibility_mode, + ) + layer_parameters: dict[str, Any] = {} + if source != "v2-default": + layer_parameters["render_policy"] = selected + if validation_target: + layer_parameters["validation_target"] = validation_target + layers = [dict(layer) for layer in policy_layers or ()] + if layer_parameters: + layers.append( + { + "source": "render", + "policy_id": f"render-context-{selected}", + "version": "1", + "parameters": layer_parameters, + } + ) + try: + policy_set = resolve_policy_set(layers) + except PolicyResolutionError as exc: + raise ArtifactPolicyMeasurementError(str(exc)) from exc + resolved_render = str(policy_set.value("render_policy").value) + if resolved_render not in _RENDER_POLICIES: + raise ArtifactPolicyMeasurementError(f"unsupported render policy: {resolved_render}") + resolved_validation = policy_set.value("validation_target").value + render_policy = ( + dict(raw_render_policy) + if raw_render_policy is not None + else _legacy_render_policy(resolved_render, source) + ) return { - "id": f"render-{selected}", - "version": "1", - "source": "explicit-render-policy" if explicit else "compatibility-default" if compatibility else "v2-default", - "parameters": { - "style_policy": selected, - "mutates_journal_aesthetics": selected != "neutral", - }, + "schema_version": RENDER_POLICY_CONTEXT_SCHEMA, + "source": source, + "validation_source": validation_source, + "policy_set_sha256": policy_set.canonical_sha256(), + "policy_set": policy_set.to_json(), + "render_policy": render_policy, + "validation_target": resolved_validation, } @@ -72,24 +134,77 @@ def resolve_render_validation_policies( ) -> tuple[str, dict[str, Any]]: """Resolve separate validation/render policies for a render call site.""" - v2_contract = arguments.get("v2_policy_contract") is True + context = resolve_render_policy_context(arguments, target_format=target_format) + return str(context["validation_target"] or ""), dict(context["render_policy"]) + + +def _selected_render_policy( + arguments: Mapping[str, Any], + *, + target_format: str | None, + compatibility: bool, +) -> tuple[str, str, Mapping[str, Any] | None]: + raw_render_policy = arguments.get("resolved_render_policy") + if isinstance(raw_render_policy, Mapping): + return _style_from_resolved_policy(raw_render_policy), "explicit-render-policy", raw_render_policy + raw = arguments.get("style_policy") + if not isinstance(raw, str) or not raw.strip(): + raw = target_format + explicit = isinstance(raw, str) and bool(raw.strip()) + selected = str(raw).strip().lower() if explicit else "nature" if compatibility else "neutral" + if selected not in _RENDER_POLICIES: + raise ArtifactPolicyMeasurementError(f"unsupported render policy: {selected}") + source = "explicit-render-policy" if explicit else "compatibility-default" if compatibility else "v2-default" + return selected, source, None + + +def _selected_validation_target( + arguments: Mapping[str, Any], + *, + target_format: str | None, + compatibility: bool, +) -> tuple[str | None, str]: validation_target = str(arguments.get("validation_target") or "").strip().lower() - if not validation_target and not v2_contract: + source = "explicit-validation-target" if validation_target else "none" + if not validation_target and compatibility: + candidate = str(target_format or arguments.get("target_format") or "").strip().lower() try: - get_preflight_spec(target_format) + get_preflight_spec(candidate) except ValueError: pass else: - validation_target = target_format + validation_target = candidate + source = "compatibility-target-inference" if validation_target: get_preflight_spec(validation_target) - raw_render_policy = arguments.get("resolved_render_policy") - render_policy = ( - dict(raw_render_policy) - if isinstance(raw_render_policy, Mapping) - else resolve_render_policy_selection(target_format, compatibility=not v2_contract) - ) - return validation_target, render_policy + return validation_target or None, source + + +def _style_from_resolved_policy(policy: Mapping[str, Any]) -> str: + parameters = policy.get("parameters") + raw = parameters.get("style_policy") if isinstance(parameters, Mapping) else None + if not isinstance(raw, str) or not raw.strip(): + raw = parameters.get("render_policy") if isinstance(parameters, Mapping) else None + if not isinstance(raw, str) or not raw.strip(): + raw = policy.get("id") + selected = str(raw or "").strip().lower() + if selected.startswith("render-"): + selected = selected.removeprefix("render-") + if selected not in _RENDER_POLICIES: + raise ArtifactPolicyMeasurementError(f"unsupported render policy: {selected}") + return selected + + +def _legacy_render_policy(selected: str, source: str) -> dict[str, Any]: + return { + "id": f"render-{selected}", + "version": "1", + "source": source, + "parameters": { + "style_policy": selected, + "mutates_journal_aesthetics": selected != "neutral", + }, + } def measure_artifact_policy( @@ -633,6 +748,7 @@ def _canonical_sha256(value: Any) -> str: "MEASUREMENT_VERSION", "RULE_VERSION", "measure_artifact_policy", + "resolve_render_policy_context", "resolve_render_policy_selection", "resolve_render_validation_policies", "verify_artifact_policy_projection", diff --git a/hub_core/config_contract_defaults.py b/hub_core/config_contract_defaults.py new file mode 100644 index 0000000..92c9e99 --- /dev/null +++ b/hub_core/config_contract_defaults.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from .project_roles import DEFAULT_PROJECT_ROLE, project_role + + +def data_contract_bool(config: dict, key: str) -> bool | None: + data_contract = config.get("data_contract", {}) if isinstance(config, dict) else {} + if not isinstance(data_contract, dict): + return None + value = data_contract.get(key) + return value if isinstance(value, bool) else None + + +def module_default_contract_bool(config: dict, key: str) -> bool: + explicit = data_contract_bool(config, key) + if explicit is not None: + return explicit + return project_role(config) == DEFAULT_PROJECT_ROLE diff --git a/hub_core/config_parser.py b/hub_core/config_parser.py index 6c848fa..f65cae0 100644 --- a/hub_core/config_parser.py +++ b/hub_core/config_parser.py @@ -9,6 +9,7 @@ from . import config_visual_style as _config_visual_style from .config_adapter_validation import validate_named_adapter as _validate_named_adapter from .config_assemblies import validate_assemblies as _validate_assemblies_impl +from .config_contract_defaults import data_contract_bool, module_default_contract_bool # noqa: F401 from .config_language_policy import ALLOWED_LANGUAGE_POLICY_MODES as ALLOWED_LANGUAGE_POLICY_MODES from .config_language_policy import get_language_policy as _get_language_policy_impl from .config_language_policy import normalize_lang as normalize_lang @@ -46,6 +47,10 @@ from .config_top_level_keys import top_level_key_suggestion as _top_level_key_suggestion # noqa: F401 from .config_top_level_keys import validate_top_level_key_near_misses as _validate_top_level_key_near_misses from .config_visual_outputs import validate_visual_outputs as _validate_visual_outputs_impl +from .config_workflow_intent import normalize_workflow_defaults as normalize_workflow_defaults +from .config_workflow_intent import validate_workflow_intent_config as _validate_workflow_intent_config +from .config_workflow_intent import workflow_intent as workflow_intent +from .config_workflow_intent import workflow_intent_report as workflow_intent_report from .domain_analysis import DOMAIN_HELPER_NAMES from .execution_security import is_positive_finite_timeout from .logging import get_logger @@ -117,21 +122,6 @@ def find_config_path(project_dir): return None -def data_contract_bool(config: dict, key: str) -> bool | None: - data_contract = config.get("data_contract", {}) if isinstance(config, dict) else {} - if not isinstance(data_contract, dict): - return None - value = data_contract.get(key) - return value if isinstance(value, bool) else None - - -def module_default_contract_bool(config: dict, key: str) -> bool: - explicit = data_contract_bool(config, key) - if explicit is not None: - return explicit - return project_role(config) == DEFAULT_PROJECT_ROLE - - def _load_project_metadata(config_path, fallback_name): metadata = { "name": fallback_name, @@ -158,6 +148,7 @@ def _load_project_metadata(config_path, fallback_name): metadata["errors"] = [str(exc)] return metadata conf_data = normalize_project_defaults(conf_data) + conf_data = normalize_workflow_defaults(conf_data) project_section = conf_data.get("project") if not isinstance(project_section, dict): @@ -217,6 +208,7 @@ def validate_config(config, *, project_root=None): project = config.get("project") role = DEFAULT_PROJECT_ROLE + status = DEFAULT_PROJECT_STATUS if not isinstance(project, dict): errors.append("Missing or invalid 'project' section (must be a mapping).") else: @@ -233,6 +225,10 @@ def validate_config(config, *, project_root=None): if not isinstance(raw_status, str) or raw_status.strip().lower() not in ALLOWED_PROJECT_STATUSES: allowed = ", ".join(sorted(ALLOWED_PROJECT_STATUSES)) errors.append(f"Invalid project.status: '{raw_status}'. Allowed values: {allowed}.") + else: + status = raw_status.strip().lower() or DEFAULT_PROJECT_STATUS + + _validate_workflow_intent_config(errors, config.get("workflow", {}), project_status=status) modules = config.get("modules", []) if modules is None: @@ -676,6 +672,7 @@ def load_config(project_dir): logger.error(" └─ Compare with the scaffold template or fix the listed fields and rerun.") return None, None, None config = normalize_project_defaults(config) + config = normalize_workflow_defaults(config) errors = validate_config(config, project_root=project_dir) if errors: diff --git a/hub_core/config_workflow_intent.py b/hub_core/config_workflow_intent.py new file mode 100644 index 0000000..c04e4f7 --- /dev/null +++ b/hub_core/config_workflow_intent.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from typing import Any + +from .project_roles import project_status +from .workflow_intent import ( + INTENT_EXECUTION, + INTENT_PROMOTION, + INTENT_REVIEW, + WORKFLOW_INTENTS, + WorkflowIntentError, + infer_workflow_intent, + parse_workflow_intent, +) + +ALLOWED_WORKFLOW_INTENTS = set(WORKFLOW_INTENTS) + + +def compatibility_project_workflow_intent(config: dict) -> str: + if project_status(config) == "legacy": + return INTENT_REVIEW + return INTENT_EXECUTION + + +def normalize_workflow_defaults(config): + """Add in-memory workflow defaults without rewriting existing project files.""" + + if not isinstance(config, dict): + return config + workflow = config.get("workflow") + default_intent = compatibility_project_workflow_intent(config) + if workflow is None: + config["workflow"] = {"intent": default_intent} + return config + if not isinstance(workflow, dict): + return config + if "intent" not in workflow: + workflow["intent"] = default_intent + return config + try: + workflow["intent"] = parse_workflow_intent(workflow["intent"]) + except WorkflowIntentError: + pass + return config + + +def workflow_intent(config) -> str | None: + """Return the normalized declared/default workflow intent, or None when invalid.""" + + return workflow_intent_report(config)["intent"] + + +def workflow_intent_report( + config, + *, + active: bool = False, + step: object = None, + tool_name: object = None, + source: object = None, +) -> dict[str, Any]: + """Return inspectable workflow intent state with fail-closed execution flags.""" + + provenance: dict[str, object] = {"config_source": "compatibility-project-execution"} + fail_closed = False + issues: list[str] = [] + intent: str | None + + if not isinstance(config, dict): + intent = None + fail_closed = True + provenance["config_source"] = "invalid-config" + issues.append("Config root must be a YAML mapping/object.") + else: + workflow = config.get("workflow") + if workflow is None: + intent = compatibility_project_workflow_intent(config) + elif not isinstance(workflow, dict): + intent = None + fail_closed = True + provenance["config_source"] = "invalid-config-workflow" + issues.append("Invalid 'workflow' section (must be a mapping).") + elif "intent" not in workflow: + intent = compatibility_project_workflow_intent(config) + else: + provenance["config_source"] = "declared" + try: + intent = parse_workflow_intent(workflow["intent"]) + except WorkflowIntentError as exc: + intent = None + fail_closed = True + provenance["config_source"] = "declared-invalid" + issues.append(str(exc)) + + if isinstance(config, dict) and project_status(config) == "legacy": + if intent in {INTENT_EXECUTION, INTENT_PROMOTION}: + issues.append("legacy projects are read-only, non-promotable, and never execution-enabled") + intent = INTENT_REVIEW + provenance["config_source"] = "legacy" + fail_closed = True + + if intent is not None and not issues and any(value is not None for value in (step, tool_name, source)): + resolved = infer_workflow_intent( + active=active, + step=step, + tool_name=tool_name, + source=source, + requested_intent=intent, + project_status=project_status(config), + ).to_dict() + resolved["provenance"]["config_source"] = provenance["config_source"] + return resolved + + execution_allowed = intent == INTENT_EXECUTION and not fail_closed + promotion_allowed = intent == INTENT_PROMOTION and not fail_closed + return { + "schema_version": "figops-workflow-intent/1", + "intent": intent, + "provenance": provenance, + "fail_closed": fail_closed, + "execution_allowed": execution_allowed, + "promotion_allowed": promotion_allowed, + "read_only": intent == INTENT_REVIEW or fail_closed, + "promotable": promotion_allowed, + "issues": issues, + } + + +def validate_workflow_intent_config(errors: list[str], workflow: object, *, project_status: str) -> None: + if workflow is None: + workflow = {} + if not isinstance(workflow, dict): + errors.append("Invalid 'workflow' section (must be a mapping).") + return + if "intent" not in workflow: + return + try: + intent = parse_workflow_intent(workflow.get("intent")) + except WorkflowIntentError: + allowed = ", ".join(sorted(ALLOWED_WORKFLOW_INTENTS)) + errors.append( + f"Invalid workflow.intent: '{workflow.get('intent')}'. Allowed values: {allowed}. " + "Unknown intents fail closed for execution and remain inspectable." + ) + return + if project_status == "legacy" and intent in {INTENT_EXECUTION, INTENT_PROMOTION}: + errors.append( + f"project.status 'legacy' cannot declare workflow.intent '{intent}'; " + "legacy projects are read-only and non-promotable." + ) diff --git a/hub_core/dependency_python_inspection.py b/hub_core/dependency_python_inspection.py new file mode 100644 index 0000000..83a8753 --- /dev/null +++ b/hub_core/dependency_python_inspection.py @@ -0,0 +1,233 @@ +"""Conservative static dependency extraction for Python scripts. + +The scanner is deliberately read-only and bounded: it uses :mod:`ast` to +collect imports and literal arguments to common path APIs, while leaving +dynamic expressions unresolved for caller review. +""" + +from __future__ import annotations + +import ast +from typing import Any + +from .dependency_scan_common import ( + _is_external_url, + _is_static_local_path, + _looks_like_path, +) + +_PYTHON_PATH_CALLS = { + "open", + "path", + "read_csv", + "read_table", + "read_fwf", + "read_excel", + "read_json", + "read_parquet", + "read_feather", + "read_pickle", + "loadtxt", + "genfromtxt", + "fromfile", + "load_workbook", + "read_text", + "read_bytes", + "write_text", + "write_bytes", + "savefig", + "to_csv", + "to_excel", + "to_json", + "to_parquet", + "to_feather", +} +_PYTHON_LITERAL_PATH_CALLS = _PYTHON_PATH_CALLS - { + # These are methods on an already selected Path object; their first + # argument is content, not another path. Avoid turning ``write_text`` + # labels into dependency candidates. + "read_text", + "read_bytes", + "write_text", + "write_bytes", +} +_PYTHON_PATH_KEYWORDS = { + "path", + "filepath", + "filename", + "file", + "fname", + "name", + # Common pandas/numpy/scipy and workbook-loader spellings. These are + # path-bearing arguments even when the call has no positional argument. + "filepath_or_buffer", + "path_or_buf", + "fname_or_buf", + "file_or_buf", + "io", +} + + +def _line(node: ast.AST) -> int: + return int(getattr(node, "lineno", 0) or 0) + + +def _column(node: ast.AST) -> int: + return int(getattr(node, "col_offset", 0) or 0) + + +def _call_name(call: ast.Call) -> str: + function = call.func + if isinstance(function, ast.Name): + return function.id.lower() + if isinstance(function, ast.Attribute): + return function.attr.lower() + return "" + + +def _constant_path_string(node: ast.AST) -> str | None: + """Resolve a bounded ``Path("literal")`` wrapper around a path value.""" + + direct = _constant_string(node) + if direct is not None: + return direct + if isinstance(node, ast.Call) and _call_name(node) == "path" and node.args: + return _constant_string(node.args[0]) + return None + + +def _constant_string(node: ast.AST) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + # A bounded static concatenation is still an explicit literal dependency. + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + left = _constant_string(node.left) + right = _constant_string(node.right) + if left is not None and right is not None and len(left) + len(right) <= 4096: + return left + right + return None + + +def _static_assignments(tree: ast.AST) -> dict[str, str]: + assignments: dict[str, str] = {} + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + value = _constant_path_string(node.value) + if value is None: + continue + for target in node.targets: + if isinstance(target, ast.Name): + assignments[target.id] = value + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + value = _constant_path_string(node.value) if node.value is not None else None + if value is not None: + assignments[node.target.id] = value + return assignments + + +def _python_candidates(script_text: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool]: + """Extract static Python dependency candidates and unresolved findings.""" + + try: + tree = ast.parse(script_text, mode="exec") + except (SyntaxError, ValueError, TypeError): + return [], [{"kind": "parse_error", "source": "python", "reason": "Python syntax could not be parsed."}], True + + assignments = _static_assignments(tree) + candidates: list[dict[str, Any]] = [] + unresolved: list[dict[str, Any]] = [] + incomplete = False + + def add_candidate(kind: str, path: str, node: ast.AST, source: str) -> None: + text = path.strip() + if not text: + return + candidates.append( + { + "kind": kind, + "path": text, + "source": source, + "line": _line(node), + "column": _column(node), + } + ) + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + add_candidate("import", alias.name, node, "python:import") + elif isinstance(node, ast.ImportFrom): + module = "." * int(node.level or 0) + (node.module or "") + if module: + add_candidate("import", module, node, "python:from_import") + elif node.level: + add_candidate("import", "." * node.level, node, "python:relative_import") + + if isinstance(node, ast.Call): + name = _call_name(node) + if name not in _PYTHON_PATH_CALLS: + continue + # ``Path.write_text/read_text`` receive content/options, not a + # path. Their enclosing ``Path("...")`` call is still visited + # separately, so only path-bearing methods should inspect a first + # positional argument here. + arguments: list[ast.AST] = ( + list(node.args[:1]) if name in _PYTHON_LITERAL_PATH_CALLS else [] + ) + arguments.extend( + keyword.value + for keyword in node.keywords + if keyword.arg is not None and keyword.arg.lower() in _PYTHON_PATH_KEYWORDS + ) + if not arguments: + continue + found_static = False + for argument in arguments: + path = _constant_path_string(argument) + if path is None and isinstance(argument, ast.Name): + path = assignments.get(argument.id) + if path is not None and name in _PYTHON_LITERAL_PATH_CALLS and _is_static_local_path(path): + add_candidate("path_literal", path, argument, f"python:{name}") + found_static = True + elif path is None: + incomplete = True + unresolved.append( + { + "kind": "dynamic_path", + "source": f"python:{name}", + "line": _line(argument) or _line(node), + "reference": ast.unparse(argument)[:240], + "reason": "file path expression is not statically resolvable", + } + ) + elif name in _PYTHON_LITERAL_PATH_CALLS: + # Do not silently discard an empty or remote literal from + # a recognised path API. It cannot be materialised as a + # project dependency and therefore remains a blocker. + incomplete = True + unresolved.append( + { + "kind": "external_path" if _is_external_url(path) else "invalid_path_literal", + "source": f"python:{name}", + "line": _line(argument) or _line(node), + "reference": path[:240], + "reason": ( + "path literal names an external URL" + if _is_external_url(path) + else "path literal is empty" + ), + } + ) + if not found_static and not any(_constant_string(argument) is not None for argument in arguments): + incomplete = True + + # Also retain obvious path literals outside a recognised call. This is + # useful for assignments later consumed by a wrapper we cannot evaluate. + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str) and _looks_like_path(node.value): + add_candidate("path_literal", node.value, node, "python:path_literal") + + return candidates, unresolved, incomplete + + +__all__ = ["_python_candidates"] diff --git a/hub_core/dependency_r_inspection.py b/hub_core/dependency_r_inspection.py new file mode 100644 index 0000000..e4f70d5 --- /dev/null +++ b/hub_core/dependency_r_inspection.py @@ -0,0 +1,268 @@ +"""Conservative static dependency extraction for R scripts. + +R does not provide a safe standard AST in the runtime used by FigOps, so this +scanner uses a quote/comment-aware bounded call lexer. Dynamic expressions and +malformed delimiters remain unresolved findings instead of being guessed. +""" + +from __future__ import annotations + +import re +from typing import Any + +from .dependency_scan_common import _is_external_url, _is_static_local_path + +_R_PATH_CALLS = { + "source", + "read.csv", + "read_csv", + "read.delim", + "read_delim", + "read.table", + "read_table", + "readr::read_csv", + "readr::read_delim", + "readlines", + "readlines", + "readr::read_lines", + "readr::read_rds", + "readr::read_rda", + "readr::read_rds", + "readr::write_csv", + "readr::write_delim", + "readr::write_lines", + "readr::write_rds", + "readr::write_rds", + "readr::write_rds", + "readr::read_file", + "readr::write_file", + "readr::read_lines", + "readr::read_table", + "readr::read_csv", + "readr::read_tsv", + "readr::read_delim", + "readr::read_fwf", + "readr::read_log", + "readr::read_rds", + "readr::write_csv", + "readr::write_tsv", + "readr::write_delim", + "readr::write_rds", + "readr::write_excel_csv", + "readr::write_excel_csv2", + "readr::write_lines", + "readr::write_file", + "readRDS", + "readLines", + "load", + "save", + "saveRDS", + "write.csv", + "write.table", + "writeLines", + "file", + "file.exists", + "normalizePath", +} +_R_PATH_KEYWORDS = {"file", "path", "file_name", "filename", "name", "description"} +_R_CALL_RE = re.compile(r"(? tuple[bool, str | None]: + """Validate quotes/comments/brackets enough for safe call extraction.""" + + stack: list[str] = [] + quote: str | None = None + escaped = False + comment = False + pairs = { + "(": ")", + "[": "]", + "{": "}", + } + for char in script_text: + if comment: + if char in "\r\n": + comment = False + continue + if quote is not None: + if quote in {'"', "'"} and escaped: + escaped = False + elif quote in {'"', "'"} and char == "\\": + escaped = True + elif char == quote: + quote = None + continue + if char == "#": + comment = True + elif char in {'"', "'", "`"}: + quote = char + elif char in pairs: + stack.append(char) + elif char in pairs.values(): + if not stack or pairs[stack.pop()] != char: + return False, "R delimiters are unbalanced." + if quote is not None: + return False, "R string literal is unterminated." + if stack: + return False, "R delimiters are unbalanced." + return True, None + + +def _r_matching_call(script_text: str, opening: int) -> int | None: + depth = 1 + quote: str | None = None + escaped = False + comment = False + for index in range(opening + 1, len(script_text)): + char = script_text[index] + if comment: + if char in "\r\n": + comment = False + continue + if quote is not None: + if quote in {'"', "'"} and escaped: + escaped = False + elif quote in {'"', "'"} and char == "\\": + escaped = True + elif char == quote: + quote = None + continue + if char == "#": + comment = True + elif char in {'"', "'", "`"}: + quote = char + elif char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + return index + return None + + +def _r_split_arguments(text: str) -> list[str]: + values: list[str] = [] + start = 0 + depth = 0 + quote: str | None = None + escaped = False + for index, char in enumerate(text): + if quote is not None: + if quote in {'"', "'"} and escaped: + escaped = False + elif quote in {'"', "'"} and char == "\\": + escaped = True + elif char == quote: + quote = None + continue + if char in {'"', "'", "`"}: + quote = char + elif char in "([{": + depth += 1 + elif char in ")]}": + depth -= 1 + elif char == "," and depth == 0: + values.append(text[start:index].strip()) + start = index + 1 + tail = text[start:].strip() + if tail: + values.append(tail) + return values + + +def _r_string(value: str) -> str | None: + value = value.strip() + if len(value) < 2 or value[0] not in {'"', "'"} or value[-1] != value[0]: + return None + body = value[1:-1] + # Keep the evaluator intentionally bounded; R's full string semantics are + # not needed to identify an explicit path candidate. + return re.sub(r"\\([\\\"'])", r"\1", body) + + +def _r_candidates(script_text: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool]: + """Extract static R dependency candidates and unresolved findings.""" + + valid, reason = _r_scan_state(script_text) + if not valid: + return [], [{"kind": "parse_error", "source": "r", "reason": reason or "R source could not be parsed."}], True + + candidates: list[dict[str, Any]] = [] + unresolved: list[dict[str, Any]] = [] + incomplete = False + for match in _R_CALL_RE.finditer(script_text): + raw_name = match.group(1) + name = raw_name.lower() + canonical = name if "::" not in name else name + if canonical not in {item.lower() for item in _R_PATH_CALLS} and name.split("::")[-1] not in { + item.lower() for item in _R_PATH_CALLS + }: + continue + opening = match.end() - 1 + closing = _r_matching_call(script_text, opening) + if closing is None: + # The full scanner should already have caught this, but retain a + # fail-closed diagnostic if a future lexer change misses it. + return [], [{"kind": "parse_error", "source": "r", "reason": "R call delimiters are unbalanced."}], True + raw_arguments = _r_split_arguments(script_text[opening + 1 : closing]) + arguments: list[str] = [] + for index, argument in enumerate(raw_arguments): + named = re.match(r"^\s*([A-Za-z.][A-Za-z0-9_.]*)\s*=\s*(.*)$", argument, re.DOTALL) + if named: + if named.group(1).lower() in _R_PATH_KEYWORDS: + arguments.append(named.group(2).strip()) + elif index == 0: + arguments.append(argument.strip()) + if not arguments: + continue + found_static = False + for argument in arguments: + path = _r_string(argument) + if path is not None and _is_static_local_path(path): + line = script_text.count("\n", 0, match.start()) + 1 + candidates.append( + { + "kind": "path_literal", + "path": path.strip(), + "source": f"r:{raw_name}", + "line": line, + "column": match.start(), + } + ) + found_static = True + elif path is None: + incomplete = True + unresolved.append( + { + "kind": "dynamic_path", + "source": f"r:{raw_name}", + "line": script_text.count("\n", 0, match.start()) + 1, + "reference": argument[:240], + "reason": "file path expression is not statically resolvable", + } + ) + else: + # A recognised R file API with an empty or remote literal is + # not a clean scan: the reference cannot be represented as a + # local project dependency, so retain a fail-closed finding. + incomplete = True + unresolved.append( + { + "kind": "external_path" if _is_external_url(path) else "invalid_path_literal", + "source": f"r:{raw_name}", + "line": script_text.count("\n", 0, match.start()) + 1, + "reference": path[:240], + "reason": ( + "path literal names an external URL" + if _is_external_url(path) + else "path literal is empty" + ), + } + ) + if not found_static and arguments: + incomplete = True + return candidates, unresolved, incomplete + + +__all__ = ["_r_candidates"] diff --git a/hub_core/dependency_scan_common.py b/hub_core/dependency_scan_common.py new file mode 100644 index 0000000..441aa5e --- /dev/null +++ b/hub_core/dependency_scan_common.py @@ -0,0 +1,126 @@ +"""Shared low-level helpers for conservative dependency scanners. + +This module intentionally contains no language-specific parsing. Python and +R scanners use the same bounded path predicates and deterministic result +ordering so the facade can preserve one stable evidence contract. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +_PATH_SUFFIXES = { + ".csv", + ".tsv", + ".txt", + ".dat", + ".parquet", + ".json", + ".jsonl", + ".xlsx", + ".xls", + ".h5", + ".hdf5", + ".feather", + ".pkl", + ".pickle", + ".rds", + ".rda", + ".rdata", + ".yaml", + ".yml", + ".toml", + ".ini", + ".png", + ".jpg", + ".jpeg", + ".svg", + ".pdf", + ".eps", + ".tif", + ".tiff", + ".py", + ".r", +} + + +def _is_external_url(value: str) -> bool: + """Return whether a literal names a remote URL rather than a project path.""" + + return bool(re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", value.strip())) + + +def _looks_like_path(value: str) -> bool: + """Return true only for an obvious file/path literal. + + Labels, package names, and arbitrary prose are intentionally ignored. A + URL is not treated as a project dependency because it cannot be safely + materialised by a copy-only project migration. + """ + + text = value.strip() + if not text or len(text) > 4096 or _is_external_url(text): + return False + if text.startswith(("./", "../", "/", "~/", "\\")) or "/" in text or "\\" in text: + return True + return Path(text).suffix.lower() in _PATH_SUFFIXES + + +def _is_static_local_path(value: str) -> bool: + """Recognise any non-empty local literal passed to a path-bearing API. + + A suffix is not required here: ``read_csv("input")``, ``open("README")``, + and ``Path("workspace")`` are all explicit path references. The broader + ``_looks_like_path`` predicate remains intentionally conservative for + string literals found outside a recognised path API. + """ + + text = value.strip() + return bool(text) and len(text) <= 4096 and not _is_external_url(text) + + +def _deduplicate(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return deterministic scanner evidence without duplicate findings.""" + + unique: dict[tuple[Any, ...], dict[str, Any]] = {} + for item in items: + # A path literal found through a recognised file API and through the + # fallback literal walk is one dependency, not two. Keep separate + # source locations, however, because two calls on different lines may + # require independent review. + if item.get("kind") == "path_literal": + key = ( + item.get("kind"), + item.get("path"), + ) + elif item.get("kind") == "hardcoded_path": + key = ( + item.get("kind"), + item.get("path"), + item.get("line"), + item.get("column"), + ) + else: + key = tuple((field, item.get(field)) for field in sorted(item)) + unique.setdefault(key, item) + return sorted( + unique.values(), + key=lambda item: ( + int(item.get("line", 0) or 0), + int(item.get("column", 0) or 0), + str(item.get("kind", "")), + str(item.get("path", item.get("reference", ""))), + str(item.get("source", "")), + ), + ) + + +__all__ = [ + "_PATH_SUFFIXES", + "_deduplicate", + "_is_external_url", + "_is_static_local_path", + "_looks_like_path", +] diff --git a/hub_core/dependency_script_inspection.py b/hub_core/dependency_script_inspection.py new file mode 100644 index 0000000..61f10a4 --- /dev/null +++ b/hub_core/dependency_script_inspection.py @@ -0,0 +1,184 @@ +"""Conservative, read-only discovery of script dependencies. + +This facade preserves the public scanner API while delegating language-specific +lexing to :mod:`dependency_python_inspection` and +:mod:`dependency_r_inspection`. The returned mapping is JSON-friendly and +deterministic evidence for review; it never executes or rewrites source files. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Mapping + +from .dependency_python_inspection import _python_candidates +from .dependency_r_inspection import _r_candidates +from .dependency_scan_common import _deduplicate + +_SUPPORTED_SUFFIXES = {".py", ".python", ".r"} +_GROUPING_ROLE_ROOTS = {"scripts", "results"} + + +def _suffix(value: object, script_path: object = None) -> str: + """Normalise a language/suffix hint without inferring a role.""" + + candidate = value + if candidate is None and script_path is not None: + candidate = Path(script_path).suffix + text = str(candidate or "").strip().lower() + if text in {"python", "py"}: + return ".py" + if text in {"r", ".r"}: + return ".r" + if text and not text.startswith("."): + text = "." + text + return text + + +def _explicit_role(path: str, role_roots: Mapping[str, str] | None) -> str | None: + """Resolve the most-specific declared terminal root, without guessing. + + The v1.1 contract deliberately nests semantic roots below the aggregate + ``scripts`` and ``results`` roots. A dependency below + ``hub_scripts/analysis`` therefore has two lexical matches, but the + terminal ``analysis_scripts`` declaration is the only actionable one. + Aggregate roots never clear a blocker by themselves, and equal-depth + matches remain unresolved. + """ + + if not isinstance(role_roots, Mapping): + return None + normalized = path.replace("\\", "/") + if normalized.startswith("/") or normalized.startswith("../") or "/../" in normalized: + return None + normalized = normalized.lstrip("./") + matches: list[tuple[int, str]] = [] + for role, root in role_roots.items(): + if not isinstance(role, str) or not isinstance(root, str) or not root.strip(): + continue + if role in _GROUPING_ROLE_ROOTS: + continue + prefix = root.replace("\\", "/").strip("/") + if normalized == prefix or normalized.startswith(prefix + "/"): + depth = len(tuple(part for part in prefix.split("/") if part)) + matches.append((depth, role)) + if not matches: + return None + deepest = max(depth for depth, _ in matches) + roles = [role for depth, role in matches if depth == deepest] + return roles[0] if len(roles) == 1 else None + + +def analyze_dependency_script( + script: str | Path, + suffix: str | None = None, + *, + language: str | None = None, + script_path: str | Path | None = None, + role_roots: Mapping[str, str] | None = None, +) -> dict[str, Any]: + """Inspect Python/R source and return deterministic dependency evidence. + + ``role_roots`` is optional and is intentionally an explicit mapping. A + path resolves only to the most-specific terminal semantic root; aggregate + ``scripts``/``results`` roots are excluded and equal-depth matches remain + unresolved. Paths matching zero or multiple actionable roots are retained + in ``hardcoded_unresolved_references`` rather than being assigned a guessed + role. Parsing errors return ``inspectable=False`` and + ``dependency_scan_incomplete=True``; no exception escapes for malformed + source or an unreadable :class:`~pathlib.Path`. + """ + + source_path = script_path + if isinstance(script, Path): + source_path = source_path or script + if suffix is None: + suffix = script.suffix + try: + script_text = script.read_text(encoding="utf-8") + except (OSError, UnicodeError): + return { + "inspectable": False, + "dependency_scan_incomplete": True, + "static_candidates": [], + "hardcoded_unresolved_references": [ + {"kind": "read_error", "source": str(script), "reason": "script could not be read."} + ], + } + elif isinstance(script, str): + script_text = script + else: + return { + "inspectable": False, + "dependency_scan_incomplete": True, + "static_candidates": [], + "hardcoded_unresolved_references": [ + {"kind": "input_error", "source": "script", "reason": "script source must be text or a Path."} + ], + } + + normalized_suffix = _suffix(language or suffix, source_path) + if normalized_suffix not in _SUPPORTED_SUFFIXES: + return { + "inspectable": False, + "dependency_scan_incomplete": True, + "static_candidates": [], + "hardcoded_unresolved_references": [ + { + "kind": "unsupported_language", + "source": normalized_suffix or "unknown", + "reason": "only Python and R source are statically inspectable.", + } + ], + } + + if normalized_suffix == ".py": + candidates, unresolved, incomplete = _python_candidates(script_text) + else: + candidates, unresolved, incomplete = _r_candidates(script_text) + + candidates = _deduplicate(candidates) + unresolved = _deduplicate(unresolved) + + # A literal path is actionable evidence but remains unresolved until the + # caller supplies an exact, unambiguous terminal semantic role mapping. + # Aggregate roots are deliberately excluded and ties remain unresolved. + # This is the important boundary: the scanner never decides that ``data/`` + # means raw, results, or any other semantic role. + for candidate in candidates: + if candidate.get("kind") != "path_literal": + continue + path = str(candidate.get("path") or "") + if _explicit_role(path, role_roots) is not None: + continue + unresolved.append( + { + "kind": "hardcoded_path", + "path": path, + "source": candidate.get("source", ""), + "line": candidate.get("line", 0), + "column": candidate.get("column", 0), + "reason": "hard-coded path has no single explicit declared role mapping", + } + ) + + return { + "inspectable": not any(item.get("kind") == "parse_error" for item in unresolved), + "dependency_scan_incomplete": bool(incomplete), + "static_candidates": candidates, + "hardcoded_unresolved_references": _deduplicate(unresolved), + } + + +# Descriptive aliases keep the API discoverable for callers that use +# ``inspect`` or ``scan`` terminology while preserving one implementation. +inspect_dependency_script = analyze_dependency_script +scan_dependency_script = analyze_dependency_script +inspect_script_dependencies = analyze_dependency_script + +__all__ = [ + "analyze_dependency_script", + "inspect_dependency_script", + "scan_dependency_script", + "inspect_script_dependencies", +] diff --git a/hub_core/evidence_contract.py b/hub_core/evidence_contract.py index d6b6eef..a54752a 100644 --- a/hub_core/evidence_contract.py +++ b/hub_core/evidence_contract.py @@ -30,6 +30,7 @@ _AVAILABILITY = {"available", "unavailable", "not_applicable", "unknown"} _POLICY_KEYS = {"passed", "severity", "outcome", "hard", "blocked"} _SHA256 = re.compile(r"^[0-9a-fA-F]{64}$") +_RENDER_POLICY_CONTEXT_SCHEMA = "figops-render-policy-context/1" class EvidenceContractError(ValueError): @@ -286,16 +287,48 @@ def _validate_provenance(value: Any, *, require_hashes: bool) -> None: _nonempty_string(provenance.get("reason"), "evidence.provenance.reason") -def _validate_resolved_policy(value: Any) -> None: +def _validate_resolved_policy(value: Any, path: str = "evidence.resolved_policy") -> None: if value is None: return - policy = _mapping(value, "evidence.resolved_policy") - _closed(policy, {"id", "version", "source", "parameters"}, "evidence.resolved_policy") - _nonempty_string(policy.get("id"), "evidence.resolved_policy.id") - _nonempty_string(policy.get("version"), "evidence.resolved_policy.version") - _nonempty_string(policy.get("source"), "evidence.resolved_policy.source") + policy = _mapping(value, path) + _closed(policy, {"id", "version", "source", "parameters"}, path) + _nonempty_string(policy.get("id"), f"{path}.id") + _nonempty_string(policy.get("version"), f"{path}.version") + _nonempty_string(policy.get("source"), f"{path}.source") if "parameters" in policy: - _mapping(policy["parameters"], "evidence.resolved_policy.parameters") + _mapping(policy["parameters"], f"{path}.parameters") + + +def _validate_policy_context(value: Any) -> None: + if value is None: + return + context = _mapping(value, "evidence.policy_context") + _closed( + context, + {"schema_version", "source", "policy_set_sha256", "render_policy", "validation_target"}, + "evidence.policy_context", + ) + if context.get("schema_version") != _RENDER_POLICY_CONTEXT_SCHEMA: + _fail( + "POLICY_CONTEXT_SCHEMA_INVALID", + "evidence.policy_context.schema_version", + f"must be {_RENDER_POLICY_CONTEXT_SCHEMA!r}", + ) + _nonempty_string(context.get("source"), "evidence.policy_context.source") + _sha256(context.get("policy_set_sha256"), "evidence.policy_context.policy_set_sha256") + render_policy = _mapping( + context.get("render_policy"), + "evidence.policy_context.render_policy", + ) + _validate_resolved_policy( + render_policy, + "evidence.policy_context.render_policy", + ) + if "validation_target" not in context: + _fail("FIELD_REQUIRED", "evidence.policy_context.validation_target", "is required") + target = context.get("validation_target") + if target is not None: + _nonempty_string(target, "evidence.policy_context.validation_target") def _validate_summary(value: Any, path: str) -> None: @@ -446,6 +479,7 @@ def validate_evidence_envelope(envelope: Any) -> None: "artifacts", "provenance", "resolved_policy", + "policy_context", "mutation_ledger", "exact_reproducibility", "visual_comparison", @@ -501,6 +535,7 @@ def validate_evidence_envelope(envelope: Any) -> None: require_hashes=artifact_status in {"passed", "warning"} and artifacts_exist, ) _validate_resolved_policy(root.get("resolved_policy")) + _validate_policy_context(root.get("policy_context")) _validate_mutation_ledger(root.get("mutation_ledger")) if "exact_reproducibility" not in root: _fail("FIELD_REQUIRED", "evidence.exact_reproducibility", "is required") diff --git a/hub_core/human_review_receipt.py b/hub_core/human_review_receipt.py new file mode 100644 index 0000000..008b134 --- /dev/null +++ b/hub_core/human_review_receipt.py @@ -0,0 +1,67 @@ +"""Compatible facade for the closed human review receipt domain.""" + +from __future__ import annotations + +from .human_review_receipt_json import ( + opaque_concern_id, + opaque_figure_artifact_id, + opaque_principal_id, + opaque_project_id, + opaque_waiver_id, +) +from .human_review_receipt_parts import build_review_subject, build_reviewer, calculate_subject_digest +from .human_review_receipt_schema import ( + build_human_review_receipt, + canonical_human_review_receipt_bytes, + canonical_review_payload_bytes, + human_review_receipt_digest, + parse_human_review_receipt_bytes, + validate_human_review_receipt, +) +from .human_review_receipt_types import ( + CONCERN_CATEGORIES, + CONCERN_SEVERITIES, + CONCERN_STATUSES, + DECISION_SCOPES, + DECISIONS, + REVIEWER_ROLES, + SCHEMA_VERSION, + HumanReviewAuthorityBinding, + HumanReviewReceiptError, + HumanReviewReceiptIndex, + HumanReviewVerificationPolicy, + HumanReviewVerificationResult, + HumanReviewWaiverBinding, +) +from .human_review_receipt_verification import verify_human_review_receipt + +__all__ = [ + "CONCERN_CATEGORIES", + "CONCERN_SEVERITIES", + "CONCERN_STATUSES", + "DECISIONS", + "DECISION_SCOPES", + "HumanReviewAuthorityBinding", + "HumanReviewReceiptError", + "HumanReviewReceiptIndex", + "HumanReviewVerificationPolicy", + "HumanReviewVerificationResult", + "HumanReviewWaiverBinding", + "REVIEWER_ROLES", + "SCHEMA_VERSION", + "build_human_review_receipt", + "build_review_subject", + "build_reviewer", + "calculate_subject_digest", + "canonical_human_review_receipt_bytes", + "canonical_review_payload_bytes", + "human_review_receipt_digest", + "opaque_concern_id", + "opaque_figure_artifact_id", + "opaque_principal_id", + "opaque_project_id", + "opaque_waiver_id", + "parse_human_review_receipt_bytes", + "validate_human_review_receipt", + "verify_human_review_receipt", +] diff --git a/hub_core/human_review_receipt_json.py b/hub_core/human_review_receipt_json.py new file mode 100644 index 0000000..93e2457 --- /dev/null +++ b/hub_core/human_review_receipt_json.py @@ -0,0 +1,208 @@ +"""JSON, canonicalization, and scalar helpers for human review receipts.""" + +from __future__ import annotations + +import hashlib +import json +import re +import unicodedata +from datetime import UTC, datetime +from typing import Any, Mapping, NoReturn + +from .human_review_receipt_types import HumanReviewReceiptError + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_OPAQUE_32_RE = re.compile(r"^[0-9a-f]{32}$") +_RECEIPT_ID_RE = re.compile(r"^review:sha256:([0-9a-f]{64})$") +_AUTHORITY_ASSERTION_RE = re.compile(r"^[a-z][a-z0-9-]*/[1-9][0-9]*$") +_RFC3339_UTC_SECONDS_RE = re.compile( + r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" +) +_ABSOLUTE_OR_MUTABLE_REF_RE = re.compile( + r"^(?:[A-Za-z]:[\\/]|[\\/]{1,2}|~(?:[\\/]|$)|(?:file|https?|runtime|raw):)", + re.IGNORECASE, +) +_DOMAIN_PREFIX = b"figops-human-review-v1\0" + + +def fail(message: str) -> NoReturn: + raise HumanReviewReceiptError(f"human review receipt {message}") + + +def closed_mapping(value: Any, allowed: set[str] | frozenset[str], field: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + fail(f"{field} must be a mapping") + keys = set(value) + non_string = [key for key in keys if not isinstance(key, str)] + if non_string: + fail(f"{field} keys must be strings") + missing = sorted(allowed - keys) + unknown = sorted(keys - allowed) + if missing or unknown: + detail = [] + if missing: + detail.append(f"missing {', '.join(missing)}") + if unknown: + detail.append(f"unsupported {', '.join(unknown)}") + fail(f"{field} contains {' and '.join(detail)}") + return value + + +def text(value: Any, field: str, *, max_length: int) -> str: + if not isinstance(value, str) or not value: + fail(f"{field} must be a non-empty string") + value = unicodedata.normalize("NFC", value) + if value != value.strip() or len(value) > max_length: + fail(f"{field} must be canonical and at most {max_length} characters") + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in value): + fail(f"{field} may not contain control characters") + return value + + +def sha256(value: Any, field: str) -> str: + value = text(value, field, max_length=64) + if _SHA256_RE.fullmatch(value) is None: + fail(f"{field} must be a lowercase SHA-256 digest") + return value + + +def receipt_id(value: Any, field: str) -> str: + value = text(value, field, max_length=78) + if _RECEIPT_ID_RE.fullmatch(value) is None: + fail(f"{field} must be review:sha256:") + return value + + +def opaque_id(value: Any, field: str, namespace: str) -> str: + value = text(value, field, max_length=len(namespace) + 33) + prefix, separator, suffix = value.partition(":") + if prefix != namespace or separator != ":" or _OPAQUE_32_RE.fullmatch(suffix) is None: + fail(f"{field} must be an opaque {namespace}:<128-bit-hex> identifier") + return value + + +def enum_value(value: Any, field: str, allowed: frozenset[str]) -> str: + value = text(value, field, max_length=64) + if value not in allowed: + fail(f"{field} has an unsupported value") + return value + + +def timestamp(value: Any, field: str) -> str: + value = text(value, field, max_length=20) + if _RFC3339_UTC_SECONDS_RE.fullmatch(value) is None: + fail(f"{field} must be an RFC 3339 UTC timestamp with seconds precision and Z suffix") + try: + datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) + except ValueError as exc: + raise HumanReviewReceiptError(f"human review receipt {field} must be a real timestamp") from exc + return value + + +def parse_timestamp(value: str) -> datetime: + return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) + + +def authority_assertion(value: Any, field: str = "reviewer.authority_assertion") -> str: + value = text(value, field, max_length=64) + if _AUTHORITY_ASSERTION_RE.fullmatch(value) is None: + fail(f"{field} must be a versioned policy binding token") + return value + + +def reject_path_like_subject(value: str, field: str) -> None: + if _ABSOLUTE_OR_MUTABLE_REF_RE.search(value) or ".." in value or "\\" in value or "/" in value: + fail(f"{field} must not contain an absolute path, runtime/raw URI, or mutable path-like reference") + + +def canonical_json_bytes(value: Mapping[str, Any]) -> bytes: + try: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode( + "utf-8" + ) + except (TypeError, ValueError, OverflowError, RecursionError) as exc: + raise HumanReviewReceiptError(f"human review receipt must be finite JSON: {exc}") from exc + + +def digest(domain: str, payload: bytes) -> str: + return hashlib.sha256(_DOMAIN_PREFIX + domain.encode("ascii") + b"\0" + payload).hexdigest() + + +def opaque_domain_id(namespace: str, value: Any) -> str: + value = text(value, f"{namespace}_source_id", max_length=1024) + return f"{namespace}:{digest(f'opaque:{namespace}', value.encode('utf-8'))[:32]}" + + +def opaque_project_id(value: Any) -> str: + return opaque_domain_id("project", value) + + +def opaque_principal_id(value: Any) -> str: + return opaque_domain_id("principal", value) + + +def opaque_figure_artifact_id(value: Any) -> str: + return opaque_domain_id("result.figure", value) + + +def opaque_concern_id(value: Any) -> str: + return opaque_domain_id("concern", value) + + +def opaque_waiver_id(value: Any) -> str: + return opaque_domain_id("waiver", value) + + +def _normalize_json_value(value: Any) -> Any: + if isinstance(value, str): + return unicodedata.normalize("NFC", value) + if isinstance(value, list): + return [_normalize_json_value(item) for item in value] + if isinstance(value, dict): + result: dict[str, Any] = {} + for key, child in value.items(): + if not isinstance(key, str): + fail("JSON object keys must be strings") + normalized_key = unicodedata.normalize("NFC", key) + if normalized_key in result: + raise HumanReviewReceiptError( + f"human review receipt contains duplicate JSON key after NFC normalization {normalized_key!r}" + ) + result[normalized_key] = _normalize_json_value(child) + return result + if value is None or isinstance(value, bool): + return value + fail("may contain only strings, booleans, null, arrays, and objects") + + +def _json_pairs_no_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise HumanReviewReceiptError(f"human review receipt contains duplicate JSON key {key!r}") + result[key] = value + return result + + +def _json_constant(value: str) -> NoReturn: + raise HumanReviewReceiptError(f"human review receipt contains non-finite JSON value {value}") + + +def parse_json_bytes(data: bytes | bytearray | memoryview) -> Mapping[str, Any]: + try: + raw = bytes(data) + except (TypeError, ValueError) as exc: + raise HumanReviewReceiptError("human review receipt JSON input must be bytes") from exc + if raw.startswith(b"\xef\xbb\xbf"): + fail("JSON bytes must be UTF-8 without a BOM") + try: + decoded = raw.decode("utf-8") + parsed = json.loads(decoded, object_pairs_hook=_json_pairs_no_duplicates, parse_constant=_json_constant) + except UnicodeDecodeError as exc: + raise HumanReviewReceiptError("human review receipt JSON bytes must be valid UTF-8") from exc + except json.JSONDecodeError as exc: + raise HumanReviewReceiptError(f"human review receipt JSON is invalid: {exc.msg}") from exc + normalized = _normalize_json_value(parsed) + if not isinstance(normalized, Mapping): + fail("top-level JSON value must be an object") + return normalized diff --git a/hub_core/human_review_receipt_parts.py b/hub_core/human_review_receipt_parts.py new file mode 100644 index 0000000..2b3f2cc --- /dev/null +++ b/hub_core/human_review_receipt_parts.py @@ -0,0 +1,212 @@ +"""Subject, reviewer, concern, and waiver validation for review receipts.""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence + +from .human_review_receipt_json import ( + authority_assertion, + canonical_json_bytes, + closed_mapping, + digest, + enum_value, + fail, + opaque_id, + parse_timestamp, + reject_path_like_subject, + sha256, + timestamp, +) +from .human_review_receipt_types import ( + CONCERN_CATEGORIES, + CONCERN_SEVERITIES, + CONCERN_STATUSES, + DECISION_SCOPES, + IDENTITY_KINDS, + REVIEWER_ROLES, +) + +SUBJECT_FIELDS = { + "project_id", + "artifact_id", + "artifact_sha256", + "lineage_receipt_sha256", + "evidence_digest", + "resolved_policy_digest", + "subject_digest", +} +REVIEWER_FIELDS = {"principal_id", "role", "authority_assertion", "identity_kind"} +CONCERN_FIELDS = {"concern_id", "category", "severity", "status", "subject_digest", "finding_digest"} +WAIVER_FIELDS = { + "waiver_id", + "concern_id", + "policy_rule", + "rationale_digest", + "authorized_principal_id", + "authorized_role", + "subject_digest", + "expires_at", +} + + +def calculate_subject_digest(subject: Mapping[str, Any], decision_scope: str) -> str: + scope = enum_value(decision_scope, "decision_scope", DECISION_SCOPES) + partial = closed_mapping(subject, SUBJECT_FIELDS - {"subject_digest"}, "subject") + normalized = { + "project_id": opaque_id(partial["project_id"], "subject.project_id", "project"), + "artifact_id": opaque_id(partial["artifact_id"], "subject.artifact_id", "result.figure"), + "artifact_sha256": sha256(partial["artifact_sha256"], "subject.artifact_sha256"), + "lineage_receipt_sha256": sha256(partial["lineage_receipt_sha256"], "subject.lineage_receipt_sha256"), + "evidence_digest": sha256(partial["evidence_digest"], "subject.evidence_digest"), + "resolved_policy_digest": sha256(partial["resolved_policy_digest"], "subject.resolved_policy_digest"), + } + for field, value in normalized.items(): + reject_path_like_subject(value, f"subject.{field}") + return digest("subject", canonical_json_bytes({"decision_scope": scope, "subject": normalized})) + + +def build_review_subject( + *, + project_id: str, + artifact_id: str, + artifact_sha256: str, + lineage_receipt_sha256: str, + evidence_digest: str, + resolved_policy_digest: str, + decision_scope: str, +) -> dict[str, str]: + subject = { + "project_id": project_id, + "artifact_id": artifact_id, + "artifact_sha256": artifact_sha256, + "lineage_receipt_sha256": lineage_receipt_sha256, + "evidence_digest": evidence_digest, + "resolved_policy_digest": resolved_policy_digest, + } + return {**subject, "subject_digest": calculate_subject_digest(subject, decision_scope)} + + +def build_reviewer( + *, + principal_id: str, + role: str, + authority_assertion: str, + identity_kind: str = "local_attestation", +) -> dict[str, str]: + return validate_reviewer( + { + "principal_id": principal_id, + "role": role, + "authority_assertion": authority_assertion, + "identity_kind": identity_kind, + } + ) + + +def validate_subject(value: Any, decision_scope: str) -> dict[str, str]: + subject = closed_mapping(value, SUBJECT_FIELDS, "subject") + normalized = { + "project_id": opaque_id(subject["project_id"], "subject.project_id", "project"), + "artifact_id": opaque_id(subject["artifact_id"], "subject.artifact_id", "result.figure"), + "artifact_sha256": sha256(subject["artifact_sha256"], "subject.artifact_sha256"), + "lineage_receipt_sha256": sha256(subject["lineage_receipt_sha256"], "subject.lineage_receipt_sha256"), + "evidence_digest": sha256(subject["evidence_digest"], "subject.evidence_digest"), + "resolved_policy_digest": sha256(subject["resolved_policy_digest"], "subject.resolved_policy_digest"), + "subject_digest": sha256(subject["subject_digest"], "subject.subject_digest"), + } + for field, value in normalized.items(): + reject_path_like_subject(value, f"subject.{field}") + partial = {key: normalized[key] for key in SUBJECT_FIELDS - {"subject_digest"}} + if normalized["subject_digest"] != calculate_subject_digest(partial, decision_scope): + fail("subject.subject_digest does not match the normalized subject and decision scope") + return normalized + + +def validate_reviewer(value: Any) -> dict[str, str]: + reviewer = closed_mapping(value, REVIEWER_FIELDS, "reviewer") + return { + "principal_id": opaque_id(reviewer["principal_id"], "reviewer.principal_id", "principal"), + "role": enum_value(reviewer["role"], "reviewer.role", REVIEWER_ROLES), + "authority_assertion": authority_assertion(reviewer["authority_assertion"]), + "identity_kind": enum_value(reviewer["identity_kind"], "reviewer.identity_kind", IDENTITY_KINDS), + } + + +def validate_concerns(value: Any, subject_digest: str) -> tuple[dict[str, str], ...]: + if not isinstance(value, (list, tuple)): + fail("concerns must be an array") + normalized: list[dict[str, str]] = [] + seen: set[str] = set() + for index, raw in enumerate(value): + field = f"concerns[{index}]" + concern = closed_mapping(raw, CONCERN_FIELDS, field) + item = { + "concern_id": opaque_id(concern["concern_id"], f"{field}.concern_id", "concern"), + "category": enum_value(concern["category"], f"{field}.category", CONCERN_CATEGORIES), + "severity": enum_value(concern["severity"], f"{field}.severity", CONCERN_SEVERITIES), + "status": enum_value(concern["status"], f"{field}.status", CONCERN_STATUSES), + "subject_digest": sha256(concern["subject_digest"], f"{field}.subject_digest"), + "finding_digest": sha256(concern["finding_digest"], f"{field}.finding_digest"), + } + if item["subject_digest"] != subject_digest: + fail(f"{field}.subject_digest must match subject.subject_digest") + if item["concern_id"] in seen: + fail("concerns contains duplicate concern_id values") + seen.add(item["concern_id"]) + normalized.append(item) + return tuple(normalized) + + +def validate_waivers(value: Any, subject_digest: str, reviewed_at: str) -> tuple[dict[str, str], ...]: + if not isinstance(value, (list, tuple)): + fail("waivers must be an array") + normalized: list[dict[str, str]] = [] + reviewed = parse_timestamp(reviewed_at) + seen: set[str] = set() + for index, raw in enumerate(value): + field = f"waivers[{index}]" + waiver = closed_mapping(raw, WAIVER_FIELDS, field) + item = { + "waiver_id": opaque_id(waiver["waiver_id"], f"{field}.waiver_id", "waiver"), + "concern_id": opaque_id(waiver["concern_id"], f"{field}.concern_id", "concern"), + "policy_rule": authority_assertion(waiver["policy_rule"], f"{field}.policy_rule"), + "rationale_digest": sha256(waiver["rationale_digest"], f"{field}.rationale_digest"), + "authorized_principal_id": opaque_id( + waiver["authorized_principal_id"], f"{field}.authorized_principal_id", "principal" + ), + "authorized_role": enum_value(waiver["authorized_role"], f"{field}.authorized_role", REVIEWER_ROLES), + "subject_digest": sha256(waiver["subject_digest"], f"{field}.subject_digest"), + "expires_at": timestamp(waiver["expires_at"], f"{field}.expires_at"), + } + if item["subject_digest"] != subject_digest: + fail(f"{field}.subject_digest must match subject.subject_digest") + if parse_timestamp(item["expires_at"]) <= reviewed: + fail(f"{field}.expires_at must be later than reviewed_at") + if item["waiver_id"] in seen: + fail("waivers contains duplicate waiver_id values") + seen.add(item["waiver_id"]) + normalized.append(item) + return tuple(normalized) + + +def validate_concern_waiver_links( + decision: str, + concerns: Sequence[Mapping[str, str]], + waivers: Sequence[Mapping[str, str]], +) -> None: + concern_by_id = {concern["concern_id"]: concern for concern in concerns} + waiver_ids = [waiver["concern_id"] for waiver in waivers] + if len(waiver_ids) != len(set(waiver_ids)): + fail("waivers contains duplicate concern_id values") + if set(waiver_ids) - set(concern_by_id): + fail("waivers must reference existing concerns") + for concern in concerns: + has_waiver = concern["concern_id"] in waiver_ids + if concern["status"] == "waived" and not has_waiver: + fail("waived concerns require a matching waiver") + if concern["status"] != "waived" and has_waiver: + fail("waivers may only reference waived concerns") + if concern["category"] == "scientific" and concern["severity"] == "required" and has_waiver: + fail("required scientific concerns cannot be waived") + if decision == "approve_for_promotion" and concern["status"] == "unresolved": + fail("approve_for_promotion cannot record unresolved concerns") diff --git a/hub_core/human_review_receipt_schema.py b/hub_core/human_review_receipt_schema.py new file mode 100644 index 0000000..c0690fb --- /dev/null +++ b/hub_core/human_review_receipt_schema.py @@ -0,0 +1,153 @@ +"""Closed schema construction and canonicalization for human review receipts.""" + +from __future__ import annotations + +import hashlib +from typing import Any, Mapping, Sequence + +from .human_review_receipt_json import ( + canonical_json_bytes, + closed_mapping, + enum_value, + fail, + parse_json_bytes, + parse_timestamp, + receipt_id, + sha256, + timestamp, +) +from .human_review_receipt_parts import ( + SUBJECT_FIELDS, + calculate_subject_digest, + validate_concern_waiver_links, + validate_concerns, + validate_reviewer, + validate_subject, + validate_waivers, +) +from .human_review_receipt_types import DECISION_SCOPES, DECISIONS, SCHEMA_VERSION + +TOP_LEVEL_FIELDS = { + "schema_version", + "receipt_id", + "decision", + "decision_scope", + "subject", + "reviewer", + "reviewed_at", + "expires_at", + "concerns", + "waivers", + "supersedes", + "integrity", +} +PAYLOAD_FIELDS = TOP_LEVEL_FIELDS - {"receipt_id", "integrity"} + + +def validated_payload(value: Mapping[str, Any]) -> dict[str, Any]: + payload = closed_mapping(value, PAYLOAD_FIELDS, "payload") + if payload["schema_version"] != SCHEMA_VERSION: + fail(f"schema_version must be {SCHEMA_VERSION!r}") + decision = enum_value(payload["decision"], "decision", DECISIONS) + decision_scope = enum_value(payload["decision_scope"], "decision_scope", DECISION_SCOPES) + subject = validate_subject(payload["subject"], decision_scope) + reviewer = validate_reviewer(payload["reviewer"]) + reviewed_at = timestamp(payload["reviewed_at"], "reviewed_at") + expires_at = timestamp(payload["expires_at"], "expires_at") + if parse_timestamp(expires_at) <= parse_timestamp(reviewed_at): + fail("expires_at must be later than reviewed_at") + concerns = validate_concerns(payload["concerns"], subject["subject_digest"]) + waivers = validate_waivers(payload["waivers"], subject["subject_digest"], reviewed_at) + validate_concern_waiver_links(decision, concerns, waivers) + supersedes = payload["supersedes"] + if supersedes is not None: + supersedes = receipt_id(supersedes, "supersedes") + return { + "schema_version": SCHEMA_VERSION, + "decision": decision, + "decision_scope": decision_scope, + "subject": subject, + "reviewer": reviewer, + "reviewed_at": reviewed_at, + "expires_at": expires_at, + "concerns": [dict(item) for item in concerns], + "waivers": [dict(item) for item in waivers], + "supersedes": supersedes, + } + + +def canonical_review_payload_bytes(receipt_or_payload: Mapping[str, Any]) -> bytes: + if not isinstance(receipt_or_payload, Mapping): + fail("must be a mapping") + payload = {key: receipt_or_payload[key] for key in receipt_or_payload if key not in {"receipt_id", "integrity"}} + return canonical_json_bytes(validated_payload(payload)) + + +def human_review_receipt_digest(receipt_or_payload: Mapping[str, Any]) -> str: + return hashlib.sha256(canonical_review_payload_bytes(receipt_or_payload)).hexdigest() + + +def validate_human_review_receipt(value: Mapping[str, Any]) -> dict[str, Any]: + receipt = closed_mapping(value, TOP_LEVEL_FIELDS, "receipt") + payload = validated_payload({key: receipt[key] for key in PAYLOAD_FIELDS}) + payload_digest = hashlib.sha256(canonical_json_bytes(payload)).hexdigest() + full_receipt_id = receipt_id(receipt["receipt_id"], "receipt_id") + integrity = closed_mapping(receipt["integrity"], {"canonical_sha256"}, "integrity") + canonical_sha256 = sha256(integrity["canonical_sha256"], "integrity.canonical_sha256") + if full_receipt_id != f"review:sha256:{payload_digest}": + fail("receipt_id does not match canonical payload digest") + if canonical_sha256 != payload_digest: + fail("integrity.canonical_sha256 does not match canonical payload digest") + return {**payload, "receipt_id": full_receipt_id, "integrity": {"canonical_sha256": canonical_sha256}} + + +def build_human_review_receipt( + *, + decision: str, + decision_scope: str, + subject: Mapping[str, Any], + reviewer: Mapping[str, Any], + reviewed_at: str, + expires_at: str, + concerns: Sequence[Mapping[str, Any]] = (), + waivers: Sequence[Mapping[str, Any]] = (), + supersedes: str | None = None, +) -> dict[str, Any]: + payload = validated_payload( + { + "schema_version": SCHEMA_VERSION, + "decision": decision, + "decision_scope": decision_scope, + "subject": subject, + "reviewer": reviewer, + "reviewed_at": reviewed_at, + "expires_at": expires_at, + "concerns": concerns, + "waivers": waivers, + "supersedes": supersedes, + } + ) + payload_digest = hashlib.sha256(canonical_json_bytes(payload)).hexdigest() + return { + **payload, + "receipt_id": f"review:sha256:{payload_digest}", + "integrity": {"canonical_sha256": payload_digest}, + } + + +def canonical_human_review_receipt_bytes(receipt: Mapping[str, Any]) -> bytes: + return canonical_json_bytes(validate_human_review_receipt(receipt)) + + +def parse_human_review_receipt_bytes(data: bytes | bytearray | memoryview) -> dict[str, Any]: + return validate_human_review_receipt(parse_json_bytes(data)) + + +def normalize_expected_subject(expected_subject: Mapping[str, Any], decision_scope: str) -> dict[str, str]: + if not isinstance(expected_subject, Mapping): + fail("expected_subject must be a mapping") + if set(expected_subject) == SUBJECT_FIELDS - {"subject_digest"}: + subject = dict(expected_subject) + subject["subject_digest"] = calculate_subject_digest(subject, decision_scope) + return validate_subject(subject, decision_scope) + return validate_subject(expected_subject, decision_scope) diff --git a/hub_core/human_review_receipt_types.py b/hub_core/human_review_receipt_types.py new file mode 100644 index 0000000..511ba92 --- /dev/null +++ b/hub_core/human_review_receipt_types.py @@ -0,0 +1,72 @@ +"""Shared types for the closed human review receipt domain.""" + +from __future__ import annotations + +from dataclasses import dataclass + +SCHEMA_VERSION = "figops-human-review/1" + +DECISIONS = frozenset({"approve_for_promotion", "request_revision", "decline"}) +DECISION_SCOPES = frozenset( + { + "figure_scientific_and_communication", + "figure_visual_communication", + "scientific_claim_support", + } +) +REVIEWER_ROLES = frozenset({"scientific_reviewer", "principal_investigator", "corresponding_author"}) +CONCERN_CATEGORIES = frozenset({"scientific", "communication", "accessibility", "policy", "provenance"}) +CONCERN_SEVERITIES = frozenset({"required", "advisory"}) +CONCERN_STATUSES = frozenset({"resolved", "waived", "unresolved"}) +IDENTITY_KINDS = frozenset({"local_attestation"}) + + +class HumanReviewReceiptError(ValueError): + """Raised when a human review receipt fails the closed contract.""" + + +@dataclass(frozen=True, slots=True) +class HumanReviewAuthorityBinding: + """Exact policy binding that authorizes a reviewer for one decision scope.""" + + decision_scope: str + reviewer_role: str + authority_assertion: str + + +@dataclass(frozen=True, slots=True) +class HumanReviewWaiverBinding: + """Exact policy binding that authorizes one class of concern waiver.""" + + policy_rule: str + authorized_role: str + concern_category: str + concern_severity: str + + +@dataclass(frozen=True, slots=True) +class HumanReviewVerificationPolicy: + """Narrow verification policy for identity, reviewer authority, and waivers.""" + + allow_local_attestation: bool + reviewer_bindings: frozenset[HumanReviewAuthorityBinding] = frozenset() + waiver_bindings: frozenset[HumanReviewWaiverBinding] = frozenset() + + +@dataclass(frozen=True, slots=True) +class HumanReviewReceiptIndex: + """Storage-independent revocation/currentness facts supplied by a caller.""" + + revoked_receipt_ids: frozenset[str] = frozenset() + superseded_receipt_ids: frozenset[str] = frozenset() + current_receipt_ids: frozenset[str] | None = None + + +@dataclass(frozen=True, slots=True) +class HumanReviewVerificationResult: + """Result of fail-closed human review receipt verification.""" + + valid: bool + reason: str + receipt_id: str | None = None + canonical_sha256: str | None = None diff --git a/hub_core/human_review_receipt_verification.py b/hub_core/human_review_receipt_verification.py new file mode 100644 index 0000000..c372cb8 --- /dev/null +++ b/hub_core/human_review_receipt_verification.py @@ -0,0 +1,181 @@ +"""Fail-closed verification for closed human review receipts.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Mapping + +from .human_review_receipt_json import authority_assertion, fail, parse_timestamp, receipt_id, sha256, timestamp +from .human_review_receipt_schema import ( + normalize_expected_subject, + parse_human_review_receipt_bytes, + validate_human_review_receipt, +) +from .human_review_receipt_types import ( + CONCERN_CATEGORIES, + CONCERN_SEVERITIES, + REVIEWER_ROLES, + HumanReviewAuthorityBinding, + HumanReviewReceiptError, + HumanReviewReceiptIndex, + HumanReviewVerificationPolicy, + HumanReviewVerificationResult, + HumanReviewWaiverBinding, +) + + +def verify_human_review_receipt( + receipt_or_bytes: Mapping[str, Any] | bytes | bytearray | memoryview, + *, + policy: HumanReviewVerificationPolicy, + now: datetime | str | None = None, + expected_subject: Mapping[str, Any] | None = None, + expected_subject_digest: str | None = None, + receipt_index: HumanReviewReceiptIndex | None = None, + require_approval: bool = True, +) -> HumanReviewVerificationResult: + try: + receipt = _load_receipt(receipt_or_bytes) + digest = receipt["integrity"]["canonical_sha256"] + full_receipt_id = receipt["receipt_id"] + _validate_policy(policy) + if require_approval and receipt["decision"] != "approve_for_promotion": + return HumanReviewVerificationResult(False, "decision_not_approval", full_receipt_id, digest) + if not _reviewer_authorized(receipt, policy): + return HumanReviewVerificationResult(False, "reviewer_not_authorized", full_receipt_id, digest) + if receipt["reviewer"]["identity_kind"] == "local_attestation" and not policy.allow_local_attestation: + return HumanReviewVerificationResult(False, "local_attestation_not_allowed", full_receipt_id, digest) + time_result = _verify_times(receipt, _coerce_now(now), full_receipt_id, digest) + if time_result is not None: + return time_result + if not _waivers_authorized(receipt, policy): + return HumanReviewVerificationResult(False, "waiver_not_authorized", full_receipt_id, digest) + subject_result = _verify_subjects(receipt, expected_subject, expected_subject_digest, full_receipt_id, digest) + if subject_result is not None: + return subject_result + index_result = _verify_index(receipt_index, full_receipt_id, digest) + return index_result or HumanReviewVerificationResult(True, "valid", full_receipt_id, digest) + except (HumanReviewReceiptError, TypeError, ValueError, AttributeError) as exc: + return HumanReviewVerificationResult(False, str(exc)) + + +def _load_receipt(receipt_or_bytes: Mapping[str, Any] | bytes | bytearray | memoryview) -> dict[str, Any]: + if isinstance(receipt_or_bytes, (bytes, bytearray, memoryview)): + return parse_human_review_receipt_bytes(receipt_or_bytes) + return validate_human_review_receipt(receipt_or_bytes) + + +def _validate_policy(policy: HumanReviewVerificationPolicy) -> None: + if not isinstance(policy, HumanReviewVerificationPolicy): + fail("verification policy must be a HumanReviewVerificationPolicy") + if not isinstance(policy.allow_local_attestation, bool): + fail("verification policy allow_local_attestation must be boolean") + for binding in policy.reviewer_bindings: + if not isinstance(binding, HumanReviewAuthorityBinding): + fail("verification policy reviewer_bindings must contain HumanReviewAuthorityBinding entries") + authority_assertion(binding.authority_assertion, "verification policy reviewer authority_assertion") + for binding in policy.waiver_bindings: + if not isinstance(binding, HumanReviewWaiverBinding): + fail("verification policy waiver_bindings must contain HumanReviewWaiverBinding entries") + authority_assertion(binding.policy_rule, "verification policy waiver policy_rule") + if binding.authorized_role not in REVIEWER_ROLES: + fail("verification policy waiver authorized_role is unsupported") + if binding.concern_category not in CONCERN_CATEGORIES or binding.concern_severity not in CONCERN_SEVERITIES: + fail("verification policy waiver concern binding is unsupported") + + +def _reviewer_authorized(receipt: Mapping[str, Any], policy: HumanReviewVerificationPolicy) -> bool: + reviewer = receipt["reviewer"] + actual = HumanReviewAuthorityBinding( + decision_scope=receipt["decision_scope"], + reviewer_role=reviewer["role"], + authority_assertion=reviewer["authority_assertion"], + ) + return actual in policy.reviewer_bindings + + +def _waivers_authorized(receipt: Mapping[str, Any], policy: HumanReviewVerificationPolicy) -> bool: + concerns = {concern["concern_id"]: concern for concern in receipt["concerns"]} + for waiver in receipt["waivers"]: + concern = concerns[waiver["concern_id"]] + actual = HumanReviewWaiverBinding( + policy_rule=waiver["policy_rule"], + authorized_role=waiver["authorized_role"], + concern_category=concern["category"], + concern_severity=concern["severity"], + ) + if actual not in policy.waiver_bindings: + return False + return True + + +def _verify_times( + receipt: Mapping[str, Any], + check_time: datetime, + full_receipt_id: str, + digest: str, +) -> HumanReviewVerificationResult | None: + if check_time < parse_timestamp(receipt["reviewed_at"]): + return HumanReviewVerificationResult(False, "reviewed_at_in_future", full_receipt_id, digest) + if check_time >= parse_timestamp(receipt["expires_at"]): + return HumanReviewVerificationResult(False, "expired", full_receipt_id, digest) + for waiver in receipt["waivers"]: + if check_time >= parse_timestamp(waiver["expires_at"]): + return HumanReviewVerificationResult(False, "waiver_expired", full_receipt_id, digest) + return None + + +def _verify_subjects( + receipt: Mapping[str, Any], + expected_subject: Mapping[str, Any] | None, + expected_subject_digest: str | None, + full_receipt_id: str, + digest: str, +) -> HumanReviewVerificationResult | None: + if expected_subject is not None: + expected = normalize_expected_subject(expected_subject, receipt["decision_scope"]) + if receipt["subject"] != expected: + return HumanReviewVerificationResult(False, "subject_mismatch", full_receipt_id, digest) + if expected_subject_digest is not None: + expected_digest = sha256(expected_subject_digest, "expected_subject_digest") + if receipt["subject"]["subject_digest"] != expected_digest: + return HumanReviewVerificationResult(False, "subject_digest_mismatch", full_receipt_id, digest) + return None + + +def _verify_index( + index: HumanReviewReceiptIndex | None, + full_receipt_id: str, + digest: str, +) -> HumanReviewVerificationResult | None: + if index is None: + return None + _validate_index(index) + if full_receipt_id in index.revoked_receipt_ids: + return HumanReviewVerificationResult(False, "revoked", full_receipt_id, digest) + if full_receipt_id in index.superseded_receipt_ids: + return HumanReviewVerificationResult(False, "superseded", full_receipt_id, digest) + if index.current_receipt_ids is not None and full_receipt_id not in index.current_receipt_ids: + return HumanReviewVerificationResult(False, "not_current", full_receipt_id, digest) + return None + + +def _coerce_now(now: datetime | str | None) -> datetime: + if now is None: + return datetime.now(UTC).replace(microsecond=0) + if isinstance(now, str): + return parse_timestamp(timestamp(now, "now")) + if now.tzinfo is None or now.utcoffset() is None: + fail("now must be timezone-aware") + return now.astimezone(UTC).replace(microsecond=0) + + +def _validate_index(index: HumanReviewReceiptIndex) -> None: + if not isinstance(index, HumanReviewReceiptIndex): + fail("receipt_index must be a HumanReviewReceiptIndex") + for collection_name in ("revoked_receipt_ids", "superseded_receipt_ids"): + for item in getattr(index, collection_name): + receipt_id(item, f"receipt_index.{collection_name}[]") + if index.current_receipt_ids is not None: + for item in index.current_receipt_ids: + receipt_id(item, "receipt_index.current_receipt_ids[]") diff --git a/hub_core/mcp/phase2_render_schemas.py b/hub_core/mcp/phase2_render_schemas.py new file mode 100644 index 0000000..b7f9fa8 --- /dev/null +++ b/hub_core/mcp/phase2_render_schemas.py @@ -0,0 +1,109 @@ +"""Closed MCP response schemas for Phase 2 project-render metadata.""" + +from __future__ import annotations + +from hub_core.mcp.tool_schema_common import object_schema + +RESOLVED_POLICY_SET_SCHEMA = object_schema( + { + "schema_version": {"type": "string", "const": "figops-resolved-policy-set/1"}, + "parameters": {"type": "object", "maxProperties": 64}, + }, + required=["schema_version", "parameters"], +) + +RENDER_POLICY_SCHEMA = object_schema( + { + "id": {"type": "string"}, + "version": {"type": "string"}, + "source": {"type": "string"}, + "parameters": {"type": "object", "maxProperties": 32}, + }, + required=["id", "version", "source", "parameters"], +) + +RENDER_POLICY_CONTEXT_SCHEMA = object_schema( + { + "schema_version": {"type": "string", "const": "figops-render-policy-context/1"}, + "source": { + "type": "string", + "enum": ["explicit-render-policy", "compatibility-default", "v2-default"], + }, + "validation_source": { + "type": "string", + "enum": ["explicit-validation-target", "compatibility-target-inference", "none"], + }, + "policy_set_sha256": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"}, + "policy_set": RESOLVED_POLICY_SET_SCHEMA, + "render_policy": RENDER_POLICY_SCHEMA, + "validation_target": {"type": ["string", "null"]}, + }, + required=[ + "schema_version", + "source", + "validation_source", + "policy_set_sha256", + "policy_set", + "render_policy", + "validation_target", + ], +) + +WORKFLOW_INTENT_SCHEMA = object_schema( + { + "schema_version": {"type": "string", "const": "figops-workflow-intent/1"}, + "intent": {"type": ["string", "null"], "enum": ["exploration", "execution", "review", "promotion", None]}, + "source": { + "type": ["string", "null"], + "enum": ["explicit", "orchestrator", "mcp", "direct_csv", "read_only", "readiness", "legacy", None], + }, + "provenance": object_schema( + { + "active": {"type": "boolean"}, + "step": {"type": ["string", "null"]}, + "tool_name": {"type": "string"}, + "requested_intent": { + "type": ["string", "null"], + "enum": ["exploration", "execution", "review", "promotion", None], + }, + "requested_source": { + "type": ["string", "null"], + "enum": ["explicit", "orchestrator", "mcp", "direct_csv", "read_only", "readiness", "legacy", None], + }, + "project_status": {"type": "string"}, + "config_source": {"type": "string"}, + }, + required=[ + "active", + "step", + "tool_name", + "requested_intent", + "requested_source", + "project_status", + "config_source", + ], + ), + "fail_closed": {"type": "boolean"}, + "legacy": {"type": "boolean"}, + "execution_allowed": {"type": "boolean"}, + "promotion_allowed": {"type": "boolean"}, + "read_only": {"type": "boolean"}, + "promotable": {"type": "boolean"}, + "issues": {"type": "array", "items": {"type": "string"}, "maxItems": 64}, + }, + required=[ + "schema_version", + "intent", + "source", + "provenance", + "fail_closed", + "legacy", + "execution_allowed", + "promotion_allowed", + "read_only", + "promotable", + "issues", + ], +) + +__all__ = ["RENDER_POLICY_CONTEXT_SCHEMA", "WORKFLOW_INTENT_SCHEMA"] diff --git a/hub_core/mcp/render_project_integrity_context.py b/hub_core/mcp/render_project_integrity_context.py new file mode 100644 index 0000000..8099932 --- /dev/null +++ b/hub_core/mcp/render_project_integrity_context.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from typing import Any + + +def resolve_project_render_workflow_intent( + config: Mapping[str, Any], + *, + workflow_intent_report_fn: Callable[..., dict[str, Any]], +) -> dict[str, Any]: + """Resolve the workflow intent for the MCP project-render execution surface.""" + + return workflow_intent_report_fn( + config, + active=True, + tool_name="figops.render_project_figure", + ) + + +def resolve_project_render_policy_context( + arguments: Mapping[str, Any], + *, + target_format: str, + resolve_render_policy_context_fn: Callable[..., Mapping[str, Any]], +) -> dict[str, Any]: + """Resolve the render policy context and keep it mutable for manifest assembly.""" + + return dict( + resolve_render_policy_context_fn( + arguments, + target_format=target_format, + ) + ) + + +def apply_project_render_policy_context( + style_summary: dict[str, Any], + policy_context: Mapping[str, Any], +) -> tuple[str, dict[str, Any]]: + """Bind resolved policy identity onto the public style summary.""" + + validation_target = str(policy_context.get("validation_target") or "") + render_policy = dict(policy_context["render_policy"]) + style_summary["render_policy"] = render_policy["id"] + style_summary["validation_target"] = validation_target or None + return validation_target, render_policy + + +def decide_project_render_promotion_eligibility( + *, + claim_inventory: Mapping[str, Any], + policy_projections: Sequence[Mapping[str, Any]], + validation_target: str, + workflow_intent: Mapping[str, Any], + manual_review_needed: bool, +) -> dict[str, Any]: + """Decide review and promotion state after evidence policy projection.""" + + projection_ready = ( + len(policy_projections) == 1 + and policy_projections[0].get("status") == "informational" + ) + policy_review_needed = bool(validation_target) and not projection_ready + workflow_execution_allowed = workflow_intent.get("execution_allowed") is True + workflow_review_needed = not workflow_execution_allowed + manual_review_needed = manual_review_needed or policy_review_needed or workflow_review_needed + promotion_eligible = bool( + claim_inventory["promotion_eligible"] + and validation_target + and projection_ready + and workflow_execution_allowed + ) + return { + "manual_review_needed": manual_review_needed, + "policy_review_needed": policy_review_needed, + "projection_ready": projection_ready, + "promotion_eligible": promotion_eligible, + "workflow_execution_allowed": workflow_execution_allowed, + "workflow_review_needed": workflow_review_needed, + } + + +__all__ = [ + "apply_project_render_policy_context", + "decide_project_render_promotion_eligibility", + "resolve_project_render_policy_context", + "resolve_project_render_workflow_intent", +] diff --git a/hub_core/mcp/render_response.py b/hub_core/mcp/render_response.py index bc4cc34..68f3c98 100644 --- a/hub_core/mcp/render_response.py +++ b/hub_core/mcp/render_response.py @@ -44,6 +44,7 @@ def one_render_response(tool_name: str, result: Mapping[str, Any]) -> dict[str, "status": _text(runtime_availability.get("status")), "reason": _text(runtime_availability.get("reason")), } + _add_project_render_context(response, result) return _bounded(response) evidence = normalize_evidence_envelope(evidence_raw) @@ -64,6 +65,7 @@ def one_render_response(tool_name: str, result: Mapping[str, Any]) -> dict[str, "failure_stage": None, "resolution_hint": None, } + _add_project_render_context(response, result) return _bounded(response) @@ -161,6 +163,13 @@ def _texts(value: Any) -> list[str]: return [_text(item) for item in value[:MAX_RESPONSE_WARNINGS]] +def _add_project_render_context(response: dict[str, Any], result: Mapping[str, Any]) -> None: + for key in ("policy_context", "workflow_intent"): + value = result.get(key) + if isinstance(value, Mapping): + response[key] = dict(value) + + def _bounded(response: dict[str, Any]) -> dict[str, Any]: encoded = json.dumps(response, ensure_ascii=False, separators=(",", ":")).encode("utf-8") if len(encoded) > MAX_RENDER_RESPONSE_BYTES: diff --git a/hub_core/mcp/review_schemas.py b/hub_core/mcp/review_schemas.py new file mode 100644 index 0000000..dc16063 --- /dev/null +++ b/hub_core/mcp/review_schemas.py @@ -0,0 +1,57 @@ +"""Optional, explicitly write-gated review-recording MCP schema.""" + +from __future__ import annotations + +from typing import Any + +from .tool_schema_common import ToolDefinition, object_schema, standard_output_schema + + +def review_tool_definitions() -> list[dict[str, Any]]: + definition = ToolDefinition( + "figops.record_human_review", + "Record one validated human-review receipt below a project's declared evidence role.", + object_schema( + { + "project_path": { + "type": "string", + "minLength": 1, + "description": "Active project path under the configured research root.", + }, + "figure_id": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Trusted configured figure identifier bound to the review subject.", + }, + "relative_path": { + "type": "string", + "pattern": r"^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$", + "description": "Canonical POSIX path relative to results/evidence.", + }, + "review_receipt": { + "type": "object", + "description": "Closed figops-human-review/1 receipt; no authority is inferred from its fields.", + }, + "expected_subject": { + "type": "object", + "description": "Optional host-supplied exact subject binding for the receipt.", + }, + }, + required=["project_path", "figure_id", "relative_path", "review_receipt"], + ), + standard_output_schema( + { + "project_path": {"type": "string"}, + "relative_path": {"type": "string"}, + "receipt_id": {"type": "string"}, + "canonical_sha256": {"type": "string"}, + "size_bytes": {"type": "integer"}, + "review_receipt": {"type": "object"}, + } + ), + ) + return [definition.to_dict()] + + +__all__ = ["review_tool_definitions"] diff --git a/hub_core/mcp/schemas.py b/hub_core/mcp/schemas.py index 5fd872a..6f85282 100644 --- a/hub_core/mcp/schemas.py +++ b/hub_core/mcp/schemas.py @@ -8,6 +8,8 @@ from hub_core.mcp.discovery_schemas import list_prompt_definitions as _list_prompt_definitions from hub_core.mcp.discovery_schemas import list_resource_definitions as _list_resource_definitions from hub_core.mcp.discovery_schemas import list_resource_templates as _list_resource_templates +from hub_core.mcp.phase2_render_schemas import RENDER_POLICY_CONTEXT_SCHEMA as _RENDER_POLICY_CONTEXT_SCHEMA +from hub_core.mcp.phase2_render_schemas import WORKFLOW_INTENT_SCHEMA as _WORKFLOW_INTENT_SCHEMA from hub_core.mcp.render_geometry_schemas import GEOMETRY_DIAGNOSTICS_SCHEMA as _GEOMETRY_DIAGNOSTICS_SCHEMA from hub_core.mcp.render_geometry_schemas import GEOMETRY_METRIC_NAMES as _GEOMETRY_METRIC_NAMES # noqa: F401 from hub_core.mcp.render_geometry_schemas import LAYOUT_REPORT_SCHEMA as _LAYOUT_REPORT_SCHEMA @@ -108,7 +110,10 @@ def describe_figops_surface() -> dict[str, Any]: def list_tool_definitions( - *, profile: str | None = None, write_tools_enabled: bool | None = None + *, + profile: str | None = None, + write_tools_enabled: bool | None = None, + require_host_approval: bool = False, ) -> list[dict[str, Any]]: supported_render_plot_types = _supported_render_plot_types() root_arg = {"type": "string", "description": "Project scan root. Defaults to FigOps research root."} @@ -565,6 +570,8 @@ def list_tool_definitions( "baseline_comparison": {"type": "object"}, "provenance": {"type": "object"}, "evidence": {"type": "object"}, + "policy_context": _RENDER_POLICY_CONTEXT_SCHEMA, + "workflow_intent": _WORKFLOW_INTENT_SCHEMA, } ), ), @@ -641,7 +648,7 @@ def list_tool_definitions( } ), ), - build_normalize_project_structure_definition(), + build_normalize_project_structure_definition(include_host_approval=require_host_approval), ToolDefinition( "figops.batch_check", "Run a bounded project discovery and validation batch check with optional runtime manifest logging.", @@ -678,6 +685,15 @@ def list_tool_definitions( ), ] serialized = [definition.to_dict() for definition in definitions] + normalize_definition = next( + (definition for definition in serialized if definition["name"] == "figops.normalize_project_structure"), + None, + ) + if normalize_definition is not None and require_host_approval: + normalize_definition["description"] = ( + "Propose migration mappings or apply an explicitly reviewed copy-only structure plan. " + "Non-dry-run copy apply requires host-issued approval in this secure mode." + ) if profile is None: return serialized return select_tool_definitions(serialized, profile=profile, write_tools_enabled=bool(write_tools_enabled)) diff --git a/hub_core/mcp/security.py b/hub_core/mcp/security.py index ee26fd4..a77c868 100644 --- a/hub_core/mcp/security.py +++ b/hub_core/mcp/security.py @@ -28,8 +28,16 @@ "figops.scaffold_project", "figops.normalize_project_structure", "figops.batch_check", + "figops.record_human_review", +) +# The new review writer is deliberately additive and has no historical +# graphhub.* alias; compatibility aliases remain frozen to the pre-existing +# surface. +LEGACY_WRITE_TOOL_NAMES = tuple( + name.replace("figops.", "graphhub.", 1) + for name in WRITE_TOOL_NAMES + if name != "figops.record_human_review" ) -LEGACY_WRITE_TOOL_NAMES = tuple(name.replace("figops.", "graphhub.", 1) for name in WRITE_TOOL_NAMES) PROJECT_ID_REPARSE_ERROR = PROJECT_EXECUTION_REPARSE_ERROR.replace("execution project", "project_id") diff --git a/hub_core/mcp/server.py b/hub_core/mcp/server.py index 0c2f9e1..142f9e3 100644 --- a/hub_core/mcp/server.py +++ b/hub_core/mcp/server.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any, Callable +from hub_core.approval_authority import ApprovalAuthorityRoot from hub_core.path_identity import canonical_path, canonical_relative_to from hub_core.redaction import redact_secrets, redact_text @@ -16,6 +17,7 @@ from .prompts import McpPromptsMixin from .render_orchestration import McpRenderOrchestrationMixin from .resources import McpResourcesMixin +from .review_schemas import review_tool_definitions from .schemas import ( get_tool_handlers, ) @@ -44,6 +46,7 @@ from .tools.render_tools import McpRenderToolsMixin from .tools.render_v2 import McpRenderV2Mixin from .tools.render_validation import McpRenderValidationMixin +from .tools.review_tools import McpReviewToolsMixin class FigOpsMCPServer( @@ -52,6 +55,7 @@ class FigOpsMCPServer( McpRenderV2Mixin, McpReadToolsMixin, McpReadinessToolsMixin, + McpReviewToolsMixin, McpRenderToolsMixin, McpRenderProjectMixin, McpRenderCsvMixin, @@ -75,9 +79,41 @@ def __init__( write_tools_enabled: bool | None = None, surface_profile: str | None = None, require_initialize: bool = False, + require_host_approval: bool = False, + host_authority_root: ApprovalAuthorityRoot | None = None, + host_authority_index: ApprovalAuthorityRoot | None = None, + # Explicit aliases keep the trust channel discoverable to callers + # using the approval-domain terminology. These are constructor-only + # values and are never loaded from tool arguments, project files, or + # environment variables. + approval_authority_root: ApprovalAuthorityRoot | None = None, + approval_authority_index: ApprovalAuthorityRoot | None = None, + host_authority: ApprovalAuthorityRoot | None = None, + approval_authority: ApprovalAuthorityRoot | None = None, ) -> None: self.require_initialize = require_initialize self.initialized = False + authority_candidates = [ + candidate + for candidate in ( + host_authority_root, + host_authority_index, + approval_authority_root, + approval_authority_index, + host_authority, + approval_authority, + ) + if candidate is not None + ] + if authority_candidates and any( + candidate is not authority_candidates[0] for candidate in authority_candidates[1:] + ): + raise ValueError("Only one identical host approval authority root/index may be supplied.") + self.host_authority_root = authority_candidates[0] if authority_candidates else None + self.host_authority_index = self.host_authority_root + self.approval_authority_root = self.host_authority_root + self.approval_authority_index = self.host_authority_root + self.require_host_approval = bool(require_host_approval) if config is None: resolved_config = McpServerConfig.from_env() elif isinstance(config, McpServerConfig): @@ -102,6 +138,7 @@ def list_tool_definitions(self) -> list[dict[str, Any]]: return schema_list_tool_definitions( profile=self.surface_profile, write_tools_enabled=self.write_tools_enabled, + require_host_approval=self.require_host_approval, ) def callable_tool_definitions(self) -> list[dict[str, Any]]: @@ -115,8 +152,15 @@ def callable_tool_definitions(self) -> list[dict[str, Any]]: return schema_list_tool_definitions( profile=self.surface_profile, write_tools_enabled=True, + require_host_approval=self.require_host_approval, ) + @staticmethod + def optional_write_tool_definitions() -> list[dict[str, Any]]: + """Return additive write schemas kept outside frozen discovery profiles.""" + + return review_tool_definitions() + @staticmethod def list_resource_definitions() -> list[dict[str, str]]: return schema_list_resource_definitions() @@ -135,6 +179,12 @@ def list_prompt_definitions(self) -> list[dict[str, Any]]: def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: arguments = dict(arguments or {}) handler = self._handlers.get(name) + # The review writer is intentionally additive and is not inserted into + # the frozen v2/compatibility discovery profiles. It remains callable + # by its one canonical name so operators can opt into the write gate + # without changing historical tool counts or aliases. + if handler is None and name == "figops.record_human_review": + handler = self.record_human_review if handler is None: raise ValueError(f"Unknown FigOps MCP tool: {name}") structured = self._authorize_write_tool(name, arguments) diff --git a/hub_core/mcp/structure_schemas.py b/hub_core/mcp/structure_schemas.py index 0aceb5f..aebfcff 100644 --- a/hub_core/mcp/structure_schemas.py +++ b/hub_core/mcp/structure_schemas.py @@ -89,84 +89,99 @@ def build_project_structure_schemas() -> tuple[ ) -def build_normalize_project_structure_definition() -> ToolDefinition: +def build_normalize_project_structure_definition(*, include_host_approval: bool = False) -> ToolDefinition: """Build the stable normalization tool contract.""" - return ToolDefinition( - "figops.normalize_project_structure", - "Propose migration mappings or apply an explicitly reviewed copy-only structure plan.", - object_schema( - { - "project_path": {"type": "string"}, - "dry_run": { - "type": "boolean", - "default": True, - "description": ( - "Preview without writing files. Defaults True like scaffold_project and " - "batch_check; the two render tools default dry_run False." - ), - }, - "move_policy": { - "type": "string", - "enum": ["adopt", "copy", "move", "symlink"], - "default": "adopt", - "description": ( - "adopt returns read-only proposals; copy requires approved_mappings. " - "move and symlink remain accepted only to return a stable deprecation error." - ), - }, - "include_raw": {"type": "boolean", "default": False}, - "overwrite": { - "type": "boolean", - "default": False, - "description": "Deprecated compatibility argument; true always fails closed.", - }, - "approved_mappings": { - "type": "array", - "items": { - "type": "object", - "properties": { - "source": {"type": "string"}, - "destination": {"type": "string"}, - "role": {"type": "string"}, - }, - "required": ["source", "destination", "role"], - "additionalProperties": False, - }, - "description": "Explicit mappings accepted by the user after reviewing an adopt proposal.", - }, - "config_diff": { - "type": "array", - "items": {"type": "object"}, - "description": "Reviewed typed project_config.yaml compare-and-swap edits.", - }, - "hardcoded_unresolved_references": { - "type": "array", - "items": {}, - "description": "Unresolved dependencies that intentionally block apply.", - }, - "confirmation_token": { - "type": "string", - "description": "Exact token returned by the reviewed copy-only dry-run.", + input_properties: dict[str, Any] = { + "project_path": {"type": "string"}, + "dry_run": { + "type": "boolean", + "default": True, + "description": ( + "Preview without writing files. Defaults True like scaffold_project and " + "batch_check; the two render tools default dry_run False." + ), + }, + "move_policy": { + "type": "string", + "enum": ["adopt", "copy", "move", "symlink"], + "default": "adopt", + "description": ( + "adopt returns read-only proposals; copy requires approved_mappings. " + "move and symlink remain accepted only to return a stable deprecation error." + ), + }, + "include_raw": {"type": "boolean", "default": False}, + "overwrite": { + "type": "boolean", + "default": False, + "description": "Deprecated compatibility argument; true always fails closed.", + }, + "approved_mappings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "source": {"type": "string"}, + "destination": {"type": "string"}, + "role": {"type": "string"}, }, + "required": ["source", "destination", "role"], + "additionalProperties": False, }, - required=["project_path"], - ), - standard_output_schema( + "description": "Explicit mappings accepted by the user after reviewing an adopt proposal.", + }, + "config_diff": { + "type": "array", + "items": {"type": "object"}, + "description": "Reviewed typed project_config.yaml compare-and-swap edits.", + }, + "hardcoded_unresolved_references": { + "type": "array", + "items": {}, + "description": "Unresolved dependencies that intentionally block apply.", + }, + "confirmation_token": { + "type": "string", + "description": "Exact token returned by the reviewed copy-only dry-run.", + }, + } + output_properties: dict[str, Any] = { + "project_root": {"type": "string"}, + "planned_paths": {"type": "array", "items": {"type": "string"}}, + "manifest": {"type": "object"}, + "config_path": {"type": "string"}, + "style_summary": {"type": "object"}, + "validation": {"type": "object"}, + "proposed_mappings": {"type": "array", "items": {"type": "object"}}, + "unresolved_proposals": {"type": "array", "items": {"type": "object"}}, + "plan_digest": {"type": "string"}, + "confirmation_token": {"type": "string"}, + "originals_preserved": {"type": "boolean"}, + "rollback_journal": {"type": "object"}, + "provenance_receipt": {"type": "object"}, + } + if include_host_approval: + input_properties["approval_receipt_id"] = { + "type": "string", + "description": ( + "Host-issued approval receipt id resolved out-of-band from the trusted authority root. " + "Never provide approval JSON or reviewer fields in tool arguments." + ), + } + output_properties.update( { - "project_root": {"type": "string"}, - "planned_paths": {"type": "array", "items": {"type": "string"}}, - "manifest": {"type": "object"}, - "config_path": {"type": "string"}, - "style_summary": {"type": "object"}, - "validation": {"type": "object"}, - "proposed_mappings": {"type": "array", "items": {"type": "object"}}, - "unresolved_proposals": {"type": "array", "items": {"type": "object"}}, - "plan_digest": {"type": "string"}, - "confirmation_token": {"type": "string"}, - "originals_preserved": {"type": "boolean"}, - "rollback_journal": {"type": "object"}, - "provenance_receipt": {"type": "object"}, + "approval_receipt_id": {"type": ["string", "null"]}, + "host_approval_required": {"type": "boolean"}, + "approval_status": { + "type": "string", + "enum": ["not_required", "required", "verified", "rejected"], + }, } - ), + ) + return ToolDefinition( + "figops.normalize_project_structure", + "Propose migration mappings or apply an explicitly reviewed copy-only structure plan.", + object_schema(input_properties, required=["project_path"]), + standard_output_schema(output_properties), ) diff --git a/hub_core/mcp/tools/project_tools.py b/hub_core/mcp/tools/project_tools.py index c7bbc38..1be105c 100644 --- a/hub_core/mcp/tools/project_tools.py +++ b/hub_core/mcp/tools/project_tools.py @@ -1,10 +1,15 @@ from __future__ import annotations +import re +from collections.abc import Mapping from pathlib import Path from typing import Any +from hub_core.approval_authority import verify_approval_authority from hub_core.project_normalization import ( NORMALIZATION_CONFIRMATION_REQUIRED, + NORMALIZATION_HOST_APPROVAL_REJECTED, + NORMALIZATION_HOST_APPROVAL_REQUIRED, NORMALIZATION_OVERWRITE_DISABLED, NORMALIZATION_PLAN_REJECTED, NORMALIZATION_POLICY_DEPRECATED, @@ -17,6 +22,130 @@ from hub_core.structure_plan import confirmation_token as structure_confirmation_token +class _HostApprovalRejected(PermissionError): + """Raised when mutation-boundary host approval revalidation fails.""" + + +_HOST_AUTHORITY_ARGUMENT_KEYS = frozenset( + { + "approval", + "approval_json", + "approval_payload", + "approval_record", + "approval_receipt", + "approval_receipt_id", + "approval_status", + "algorithm", + "attestation", + "approved", + "authority", + "authority_index", + "authority_root", + "authorization", + "capability", + "capability_handle", + "currentness", + "host_approval", + "host_approval_receipt", + "host_authority", + "host_authorization", + "key_id", + "receipt", + "receipt_id", + "reviewer", + "review_record", + "reviewer_identity", + "reviewer_role", + "revocation", + "revocation_epoch", + "trust", + "trusted", + "signature", + "trust_root", + "trust_root_id", + } +) + + +def _self_described_authority_keys(arguments: dict[str, Any]) -> list[str]: + """Find authority-looking fields at every depth of a tool request. + + Host approval is deliberately an out-of-band trust channel. The one + exception is the opaque, top-level ``approval_receipt_id`` consumed by the + host authority root. Every other authority-looking field is rejected, + including fields hidden in a mapping/list payload such as + ``approved_mappings[0].reviewer`` or ``manifest.trust_root``. + """ + + def canonical_key(key: object) -> str: + return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(key).strip()).replace("-", "_").casefold() + + # Catch compound names such as ``approval_token`` and ``trusted_by`` in + # addition to the explicit compatibility list above, while keeping normal + # request fields such as ``approved_mappings`` valid. + authority_components = frozenset( + { + "approval", + "approved", + "authority", + "authorization", + "authorize", + "receipt", + "review", + "reviewer", + "signature", + "signed", + "trust", + "trusted", + } + ) + + def is_authority_key(key: object) -> bool: + canonical = canonical_key(key) + if canonical in _HOST_AUTHORITY_ARGUMENT_KEYS: + return True + return bool(authority_components.intersection(canonical.split("_"))) + + def format_path(path: tuple[object, ...]) -> str: + rendered = "" + for part in path: + if isinstance(part, int): + rendered += f"[{part}]" + else: + rendered = f"{rendered}.{part}" if rendered else str(part) + return rendered + + findings: set[str] = set() + + def visit(value: object, path: tuple[object, ...] = ()) -> None: + if isinstance(value, Mapping): + for raw_key, nested in value.items(): + key = str(raw_key) + key_path = path + (key,) + canonical = canonical_key(raw_key) + # Only this exact semantic field at the request root is + # allowed. Its value must remain opaque; if a caller embeds a + # mapping/list below it, recurse so forged nested fields still + # fail closed. + if not path and canonical == "approval_receipt_id": + if not isinstance(nested, str): + findings.add(format_path(key_path)) + visit(nested, key_path) + continue + # ``approved_mappings`` is the one ordinary request field + # whose name contains an authority word; only its top-level + # collection is part of the public normalization contract. + if not (not path and canonical == "approved_mappings") and is_authority_key(raw_key): + findings.add(format_path(key_path)) + visit(nested, key_path) + elif isinstance(value, list): + for index, nested in enumerate(value): + visit(nested, path + (index,)) + + visit(arguments) + return sorted(findings, key=str.casefold) + + class McpProjectToolsMixin: """Project scaffold and normalization MCP tool handlers.""" @@ -101,6 +230,31 @@ def normalize_project_structure(self, arguments: dict[str, Any]) -> dict[str, An guarded = self._authorize_write_tool("figops.normalize_project_structure", arguments) if guarded is not None: return guarded + if self.require_host_approval: + forbidden_authority_keys = _self_described_authority_keys(arguments) + if forbidden_authority_keys: + approval_receipt_id = arguments.get("approval_receipt_id") + return self._envelope( + "figops.normalize_project_structure", + arguments, + status="error", + summary="Self-described approval fields are not a host authority.", + errors=[ + "Tool arguments may contain only top-level opaque approval_receipt_id; " + "rejected authority fields: " + + ", ".join(forbidden_authority_keys) + + "." + ], + manual_review_needed=True, + is_dry_run=bool(arguments.get("dry_run", True)), + error_category="validation", + error_code=NORMALIZATION_HOST_APPROVAL_REJECTED, + approval_receipt_id=( + approval_receipt_id if isinstance(approval_receipt_id, str) else None + ), + host_approval_required=True, + approval_status="rejected", + ) project_path = self._resolve_under_root(arguments.get("project_path"), field_name="project_path") dry_run = bool(arguments.get("dry_run", True)) move_policy = str(arguments.get("move_policy") or "adopt").strip().lower() @@ -190,6 +344,18 @@ def normalize_project_structure(self, arguments: dict[str, Any]) -> dict[str, An "plan_digest": manifest["digest"], "confirmation_token": token, } + if self.require_host_approval: + common.update( + { + "host_approval_required": True, + "approval_receipt_id": ( + arguments.get("approval_receipt_id") + if isinstance(arguments.get("approval_receipt_id"), str) + else None + ), + "approval_status": "required", + } + ) if dry_run and move_policy == "adopt": return self._envelope( "figops.normalize_project_structure", @@ -234,6 +400,54 @@ def normalize_project_structure(self, arguments: dict[str, Any]) -> dict[str, An error_code=NORMALIZATION_CONFIRMATION_REQUIRED, **common, ) + if self.require_host_approval: + approval_receipt_id = arguments.get("approval_receipt_id") + verification = verify_approval_authority( + manifest, + approval_receipt_id, + self.host_authority_root, + ) + if not verification.valid: + reason = verification.reason.replace("_", " ") + common["approval_status"] = "rejected" + return self._envelope( + "figops.normalize_project_structure", + arguments, + status="error", + summary="Host approval is required before normalization apply.", + errors=[f"Host approval receipt was rejected: {reason}."], + manual_review_needed=True, + is_dry_run=False, + error_category="validation", + error_code=( + NORMALIZATION_HOST_APPROVAL_REQUIRED + if verification.reason + in { + "missing_or_untrusted_root", + "missing_trusted_root", + "untrusted_root", + "missing_or_invalid_receipt_id", + } + else NORMALIZATION_HOST_APPROVAL_REJECTED + ), + **common, + ) + common["approval_status"] = "verified" + pre_apply_verifier = None + if self.require_host_approval: + def _revalidate_host_approval(_root: Path, current_plan: dict[str, Any]) -> None: + boundary_verification = verify_approval_authority( + current_plan, + approval_receipt_id, + self.host_authority_root, + ) + if not boundary_verification.valid: + raise _HostApprovalRejected( + "Host approval changed before mutation boundary: " + f"{boundary_verification.reason.replace('_', ' ')}." + ) + + pre_apply_verifier = _revalidate_host_approval try: self._resolve_execution_project_path(arguments.get("project_path")) except ValueError as exc: @@ -254,6 +468,21 @@ def normalize_project_structure(self, arguments: dict[str, Any]) -> dict[str, An manifest, hub_path=self.hub_path, confirmation_token=supplied_token, + pre_apply_verifier=pre_apply_verifier, + ) + except _HostApprovalRejected as exc: + common["approval_status"] = "rejected" + return self._envelope( + "figops.normalize_project_structure", + arguments, + status="error", + summary="Host approval was rejected at the mutation boundary.", + errors=[str(exc)], + manual_review_needed=True, + is_dry_run=False, + error_category="validation", + error_code=NORMALIZATION_HOST_APPROVAL_REJECTED, + **common, ) except (FileExistsError, OSError, PermissionError, RuntimeError, ValueError) as exc: return self._envelope( @@ -270,6 +499,15 @@ def normalize_project_structure(self, arguments: dict[str, Any]) -> dict[str, An ) validation = self._validation_summary(config_path) validation_failed = validation.get("checked") is True and validation.get("valid") is False + approval_response_fields = ( + { + "approval_receipt_id": common["approval_receipt_id"], + "host_approval_required": common["host_approval_required"], + "approval_status": common["approval_status"], + } + if self.require_host_approval + else {} + ) return self._envelope( "figops.normalize_project_structure", arguments, @@ -296,6 +534,7 @@ def normalize_project_structure(self, arguments: dict[str, Any]) -> dict[str, An unresolved_proposals=common["unresolved_proposals"], plan_digest=applied["plan_digest"], confirmation_token=token, + **approval_response_fields, originals_preserved=applied["originals_preserved"], rollback_journal=applied["rollback_journal"], provenance_receipt=applied["provenance_receipt"], diff --git a/hub_core/mcp/tools/render_project.py b/hub_core/mcp/tools/render_project.py index 6e7445d..2e6626f 100644 --- a/hub_core/mcp/tools/render_project.py +++ b/hub_core/mcp/tools/render_project.py @@ -5,15 +5,22 @@ from typing import Any from hub_core.adapters import select_adapters -from hub_core.artifact_policy_measurement import resolve_render_validation_policies +from hub_core.artifact_policy_measurement import resolve_render_policy_context from hub_core.attempt_provenance import build_attempt_provenance, update_attempt_provenance -from hub_core.config_parser import master_execution_error, project_role, project_status, validate_config +from hub_core.config_parser import ( + master_execution_error, + project_role, + project_status, + validate_config, + workflow_intent_report, +) from hub_core.data_contract import validate_data_contract, validate_data_contract_preflight from hub_core.external_raw_execution import ( is_external_raw_declaration, materialize_external_raw_inputs, ) from hub_core.mcp import render_orchestration as render_helpers +from hub_core.mcp import render_project_integrity_context as integrity_context from hub_core.mcp.errors import PROJECT_DECLARATION_PATH_INVALID, has_unsafe_declared_path from hub_core.project_paths import ProjectPathError, resolve_project_input, resolve_project_output from hub_core.provenance_inputs import expand_project_input_files, resolved_research_ops_evidence @@ -123,6 +130,10 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: ), persist_failure=True, ) + workflow_intent = integrity_context.resolve_project_render_workflow_intent( + config, + workflow_intent_report_fn=workflow_intent_report, + ) research_ops = validate_research_ops_contract(project_path, config) research_ops_policy = resolved_research_ops_evidence(config) if research_ops["errors"]: @@ -274,12 +285,15 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: ) safe_output_path = True style_summary = self._selected_figure_style_summary(config, selected, arguments) - validation_target, render_policy = resolve_render_validation_policies( + policy_context = integrity_context.resolve_project_render_policy_context( arguments, target_format=style_summary["target_format"], + resolve_render_policy_context_fn=resolve_render_policy_context, + ) + validation_target, render_policy = integrity_context.apply_project_render_policy_context( + style_summary, + policy_context, ) - style_summary["render_policy"] = render_policy["id"] - style_summary["validation_target"] = validation_target or None style_errors = self._render_style_errors( style_summary["target_format"], style_summary["output_format"], @@ -354,6 +368,8 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: artifact_status="validated", baseline_comparison=self._baseline_comparison(None, arguments.get("baseline_path")), provenance={"attempt": attempt}, + policy_context=policy_context, + workflow_intent=workflow_intent, failure_stage="", resolution_hint="", ) @@ -575,6 +591,8 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: research_ops_policy=research_ops_policy, data_contract={"schema_version": "data_contract_summary/1", "passed": True}, preview_artifacts=preview_artifacts, + policy_context=policy_context, + workflow_intent=workflow_intent, ) manifest["claim_inventory"] = claim_inventory manifest["publication_status"] = ( @@ -593,22 +611,21 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: else None ), render_policy=render_policy, + policy_context=policy_context, validation_target=validation_target or None, ) policy_projections = manifest["evidence"]["policy_projections"] - projection_ready = ( - len(policy_projections) == 1 - and policy_projections[0].get("status") == "informational" + promotion_decision = integrity_context.decide_project_render_promotion_eligibility( + claim_inventory=claim_inventory, + policy_projections=policy_projections, + validation_target=validation_target, + workflow_intent=workflow_intent, + manual_review_needed=manual_review_needed, ) - policy_review_needed = bool(validation_target) and not projection_ready - manual_review_needed = manual_review_needed or policy_review_needed + manual_review_needed = bool(promotion_decision["manual_review_needed"]) status = "warning" if manual_review_needed else "ok" manifest["manual_review_needed"] = manual_review_needed - manifest["promotion_eligible"] = bool( - claim_inventory["promotion_eligible"] - and validation_target - and projection_ready - ) + manifest["promotion_eligible"] = bool(promotion_decision["promotion_eligible"]) status_payload = self._render_status_payload( job_id=job_id, status=status, @@ -628,24 +645,28 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: status_payload["claim_inventory"] = claim_inventory status_payload["publication_status"] = manifest["publication_status"] status_payload["promotion_eligible"] = manifest["promotion_eligible"] + status_payload["policy_context"] = policy_context + status_payload["workflow_intent"] = workflow_intent render_helpers._write_manifest_and_status(manifest, manifest_path, status_payload, status_path, latest_dir) - try: - promoted = promote_eligible_project_result( - project_root=project_path, - config=config, - runtime_root=self.runtime_root, - runtime_artifact=output_path, - output_relpath=output_relpath, - manifest=manifest, - manifest_path=manifest_path, - figure_id=str(selected.get("id") or "figure"), - selected_figure=selected, - ) - except Exception as exc: - raise render_helpers.ProjectRenderExportError( - f"Eligible result promotion failed: {exc}", - script_output=self._read_project_script_output(job_root), - ) from exc + promoted = None + if manifest["promotion_eligible"]: + try: + promoted = promote_eligible_project_result( + project_root=project_path, + config=config, + runtime_root=self.runtime_root, + runtime_artifact=output_path, + output_relpath=output_relpath, + manifest=manifest, + manifest_path=manifest_path, + figure_id=str(selected.get("id") or "figure"), + selected_figure=selected, + ) + except Exception as exc: + raise render_helpers.ProjectRenderExportError( + f"Eligible result promotion failed: {exc}", + script_output=self._read_project_script_output(job_root), + ) from exc if promoted is not None: created_paths.extend(str(item.path) for item in promoted) except Exception as exc: @@ -768,6 +789,8 @@ def render_project_figure(self, arguments: dict[str, Any]) -> dict[str, Any]: baseline_comparison=baseline_comparison, provenance=provenance, evidence=manifest["evidence"], + policy_context=policy_context, + workflow_intent=workflow_intent, claim_inventory=claim_inventory, publication_status=manifest["publication_status"], promotion_eligible=manifest["promotion_eligible"], diff --git a/hub_core/mcp/tools/review_tools.py b/hub_core/mcp/tools/review_tools.py new file mode 100644 index 0000000..d8c2c36 --- /dev/null +++ b/hub_core/mcp/tools/review_tools.py @@ -0,0 +1,134 @@ +"""Explicitly write-gated human-review receipt recording.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path, PurePosixPath +from typing import Any + +from hub_core.human_review_receipt import ( + canonical_human_review_receipt_bytes, + opaque_figure_artifact_id, + opaque_project_id, + validate_human_review_receipt, +) +from hub_core.project_structure_contract import resolve_project_structure +from hub_core.review_recording import record_human_review_receipt + +from .project_tools import _self_described_authority_keys + + +class McpReviewToolsMixin: + """MCP review recording handlers. + + Validation is deliberately performed before any destination is created. + The receipt itself is evidence, not a host authority; reviewer authority + is verified later by the pure promotion gate using a configured policy. + """ + + def record_human_review(self, arguments: dict[str, Any]) -> dict[str, Any]: + guarded = self._authorize_write_tool("figops.record_human_review", arguments) + if guarded is not None: + return guarded + if getattr(self, "require_host_approval", False): + # The existing host authority root is scoped to reviewed project + # structure plans. Do not reinterpret a self-described human + # review receipt as that authority or silently widen its scope. + raise ValueError( + "Host approval is required for this MCP server; review recording has no host-authority binding." + ) + # The receipt is a closed evidence DTO and therefore legitimately + # contains reviewer/authority_assertion fields. Those fields are not + # a host trust channel; scan every other argument for forged authority + # payloads before resolving a project or creating its evidence root. + authority_scan = {key: value for key, value in arguments.items() if key != "review_receipt"} + forbidden = _self_described_authority_keys(authority_scan) + if forbidden: + raise ValueError( + "Tool arguments may not self-describe host approval or authority: " + + ", ".join(forbidden) + + "." + ) + project_path = self._resolve_execution_project_path(arguments.get("project_path")) + figure_id = arguments.get("figure_id") + if not isinstance(figure_id, str) or not figure_id.strip(): + raise ValueError("figure_id is required.") + review_receipt = arguments.get("review_receipt") + if not isinstance(review_receipt, Mapping): + raise ValueError("review_receipt must be a canonical human-review receipt object.") + receipt = validate_human_review_receipt(review_receipt) + loaded = self._load_project_config(project_path) + config = loaded.get("config") + if not isinstance(config, Mapping): + raise ValueError("project configuration is required before recording a human review.") + if loaded.get("errors"): + raise ValueError("project configuration is invalid: " + "; ".join(map(str, loaded["errors"]))) + # Resolve the caller's selector against the trusted project config + # before deriving any artifact identity or touching the evidence + # root. A receipt cannot make an unconfigured (or ambiguous) + # figure ID valid merely by self-describing its subject. + figures = self._project_figure_entries(dict(config)) + selected_figure, selection_errors = self._select_project_figure( + figures, + figure_id=figure_id, + figure_output=None, + ) + if selected_figure is None or selection_errors: + detail = "; ".join(selection_errors) if selection_errors else "figure_id was not resolved." + raise ValueError("figure_id must resolve to exactly one trusted configured figure: " + detail) + contract = resolve_project_structure(config, project_root=project_path) + subject = receipt.get("subject") + if not isinstance(subject, Mapping): + raise ValueError("review_receipt.subject is required.") + project_name = config.get("project", {}).get("name") if isinstance(config.get("project"), Mapping) else None + if not isinstance(project_name, str) or not project_name.strip(): + raise ValueError("trusted project.name is required for review recording.") + expected_project_id = opaque_project_id(project_name) + expected_artifact_id = opaque_figure_artifact_id(figure_id) + if subject.get("project_id") != expected_project_id: + raise ValueError("review subject does not bind the trusted project identity.") + if subject.get("artifact_id") != expected_artifact_id: + raise ValueError("review subject does not bind the selected figure identity.") + expected_subject = arguments.get("expected_subject") + if expected_subject is not None: + if not isinstance(expected_subject, Mapping): + raise ValueError("expected_subject must be a mapping.") + if dict(expected_subject) != dict(subject): + raise ValueError("review subject does not match the trusted expected subject.") + + relative = arguments.get("relative_path") + if not isinstance(relative, str) or not relative: + raise ValueError("relative_path is required.") + evidence_root = Path(project_path) / str(contract.roots["evidence"]) + relative_path = PurePosixPath(relative) + evidence_role = PurePosixPath(str(contract.roots["evidence"])) + if relative_path.is_absolute() or ".." in relative_path.parts or "\\" in relative: + raise ValueError("relative_path must stay below the declared evidence role.") + # ``record_human_review_receipt`` receives a path relative to its + # evidence root, so an MCP caller cannot redirect into figures or + # publication by spelling a project-relative path. + if relative_path.as_posix() != relative: + raise ValueError("relative_path must use canonical POSIX separators.") + canonical_bytes = canonical_human_review_receipt_bytes(receipt) + result = record_human_review_receipt( + receipt, + evidence_root=evidence_root, + relative_path=relative_path.as_posix(), + write_authorized=True, + ) + return self._envelope( + "figops.record_human_review", + arguments, + summary="Recorded one append-only human-review receipt.", + created_paths=[str(evidence_role / relative_path)], + is_dry_run=False, + project_path=str(project_path), + relative_path=result.relative_path, + receipt_id=result.receipt_id, + canonical_sha256=result.canonical_sha256, + size_bytes=len(canonical_bytes), + review_receipt=receipt, + ) + + +__all__ = ["McpReviewToolsMixin"] diff --git a/hub_core/mcp/transport.py b/hub_core/mcp/transport.py index 52bb437..6e9349e 100644 --- a/hub_core/mcp/transport.py +++ b/hub_core/mcp/transport.py @@ -129,7 +129,10 @@ def _handle_json_rpc(server: Any, request: dict[str, Any]) -> dict[str, Any] | N if arguments is None: arguments = {} handlers = getattr(server, "_handlers", {}) - if not isinstance(tool_name, str) or tool_name not in handlers: + optional_review = tool_name == "figops.record_human_review" and callable( + getattr(server, "record_human_review", None) + ) + if not isinstance(tool_name, str) or (tool_name not in handlers and not optional_review): return _json_rpc_error(request_id, JSONRPC_INVALID_PARAMS, f"Unknown tool: {tool_name}") if not isinstance(arguments, dict): return _json_rpc_error(request_id, JSONRPC_INVALID_PARAMS, "Tool arguments must be an object.") @@ -199,7 +202,11 @@ def _callable_tool_definitions_for(server: Any) -> list[dict[str, Any]]: provider = getattr(server, "callable_tool_definitions", None) if callable(provider): - return provider() + definitions = provider() + optional_provider = getattr(server, "optional_write_tool_definitions", None) + if callable(optional_provider): + definitions = [*definitions, *optional_provider()] + return definitions return _tool_definitions_for(server) diff --git a/hub_core/mcp/v2_tool_schemas.py b/hub_core/mcp/v2_tool_schemas.py index d1646dc..15ec77d 100644 --- a/hub_core/mcp/v2_tool_schemas.py +++ b/hub_core/mcp/v2_tool_schemas.py @@ -7,6 +7,7 @@ from hub_core.artifact_audit import SUPPORTED_POLICY_PACKS from hub_core.config_parser import ALLOWED_OUTPUT_FORMATS, PUBLIC_TARGET_FORMATS from hub_core.journal_specs import list_supported_preflight_targets +from hub_core.mcp.phase2_render_schemas import RENDER_POLICY_CONTEXT_SCHEMA, WORKFLOW_INTENT_SCHEMA from hub_core.mcp.tool_schema_common import ToolDefinition, object_schema @@ -37,6 +38,8 @@ def build_v2_tool_definitions( "properties": { **render_output["properties"], "runtime_availability": {"type": "object"}, + "policy_context": RENDER_POLICY_CONTEXT_SCHEMA, + "workflow_intent": WORKFLOW_INTENT_SCHEMA, }, } return [ diff --git a/hub_core/policy_resolution.py b/hub_core/policy_resolution.py new file mode 100644 index 0000000..1959429 --- /dev/null +++ b/hub_core/policy_resolution.py @@ -0,0 +1,271 @@ +"""Pure canonical policy resolution for figure-integrity policy inputs.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Final + +from .config_style import ALLOWED_TARGET_FORMATS +from .policy_resolution_json import ( + PolicyResolutionError as PolicyResolutionError, +) +from .policy_resolution_json import ( + closed, + fail, + finite_number, + normalize_json_value, + parse_json_array, + set_value, + sha256, + token, +) +from .policy_resolution_types import ( + PolicyConstraint, + PolicyException, + ResolvedPolicySet, + ResolvedPolicyValue, + jsonable, +) + +SCHEMA_VERSION: Final = "figops-resolved-policy-set/1" +POLICY_VERSION: Final = "1" +LAYER_ORDER: Final = ("kernel", "operator", "lab", "project", "render") +PRECEDENCE: Final = {source: index for index, source in enumerate(LAYER_ORDER)} +MERGE_OPERATORS: Final = {"require", "minimum", "maximum", "allowed_set", "exact", "selection"} +JOURNAL_TARGETS: Final = tuple(sorted(ALLOWED_TARGET_FORMATS - {"neutral", "default", "ppt"})) +KERNEL_INVARIANTS: Final = frozenset( + { + "path_containment", + "schema_receipt_integrity", + "runtime_result_disjointness", + "no_replace_promotion", + } +) +_MISSING = object() + + +@dataclass(frozen=True, slots=True) +class _ParamSchema: + operator: str + default: Any = None + allowed: tuple[Any, ...] = () + opt_out_allowed: bool = False + waivable: bool = False + + +PARAMETER_SCHEMAS: Final = { + "path_containment": _ParamSchema("require", True), + "schema_receipt_integrity": _ParamSchema("require", True), + "runtime_result_disjointness": _ParamSchema("require", True), + "no_replace_promotion": _ParamSchema("require", True), + "human_signoff_required": _ParamSchema("require", False), + "render_policy": _ParamSchema("selection", "neutral", tuple(sorted(ALLOWED_TARGET_FORMATS))), + "validation_target": _ParamSchema("selection", None, (None, *JOURNAL_TARGETS)), + "minimum_raster_dpi": _ParamSchema("minimum", None), + "maximum_physical_width_mm": _ParamSchema("maximum", None), + "allowed_artifact_formats": _ParamSchema("allowed_set", None), + "project_role": _ParamSchema("exact", None), + "require_figure_traceability": _ParamSchema("require", True, opt_out_allowed=True, waivable=True), + "require_canonical_docs": _ParamSchema("require", True, opt_out_allowed=True, waivable=True), + "forbid_todo_placeholders": _ParamSchema("require", True, opt_out_allowed=True, waivable=True), + "raw_integrity_mode": _ParamSchema("selection", "off", ("off", "warn", "strict")), +} + + +def resolve_policy_set(layers: Sequence[Mapping[str, Any]] | bytes | bytearray | memoryview) -> ResolvedPolicySet: + constraints = {name: [] for name in PARAMETER_SCHEMAS} + exceptions: dict[str, list[PolicyException]] = {name: [] for name in PARAMETER_SCHEMAS} + opt_outs = dict.fromkeys(PARAMETER_SCHEMAS, False) + seen_sources: set[str] = set() + for layer in _parse_layers(layers): + layer = closed(layer, {"source", "policy_id", "version", "parameters"}, "layer") + source = _source(layer["source"], seen_sources) + precedence = PRECEDENCE[source] + policy_id = token(layer["policy_id"], "policy_id") + version = _version(layer["version"]) + params = closed(layer["parameters"], set(PARAMETER_SCHEMAS), "parameters", subset=True) + for name, raw in params.items(): + schema = PARAMETER_SCHEMAS[name] + values, opt_out, found_exceptions = _parts(name, raw, schema) + if name in KERNEL_INVARIANTS and any(value is False for value in values): + fail(f"{name} immutable kernel invariant cannot be disabled") + if opt_out and not schema.opt_out_allowed: + fail(f"{name} does not allow opt-out") + opt_outs[name] = opt_outs[name] or opt_out + constraint_source = source + if opt_out and not values: + values = [False] + constraint_source = _opt_out_source(source) + constraints[name].extend( + PolicyConstraint(constraint_source, precedence, policy_id, version, v) for v in values + ) + exceptions[name].extend( + PolicyException(source, policy_id, version, e["finding_code"], e.get("subject_digest")) + for e in found_exceptions + ) + resolved = ( + _resolve(name, PARAMETER_SCHEMAS[name], tuple(constraints[name]), opt_outs[name], tuple(exceptions[name])) + for name in PARAMETER_SCHEMAS + ) + return ResolvedPolicySet(SCHEMA_VERSION, tuple(sorted(resolved, key=lambda item: item.parameter))) + + +def compatibility_resolved_policy(policy_set: ResolvedPolicySet) -> dict[str, Any]: + """Project the canonical set into the legacy singular ``resolved_policy`` shape.""" + + render_policy = policy_set.value("render_policy").value + target = policy_set.value("validation_target").value + if target is None: + parameters = {"style_policy": render_policy, "mutates_journal_aesthetics": render_policy != "neutral"} + policy_id = f"render-{render_policy}" + else: + parameters = {"render_policy": f"render-{render_policy}", "validation_target": target} + policy_id = f"journal-{target}" + return { + "id": policy_id, + "version": POLICY_VERSION, + "source": "policy-set-compatibility-projection", + "parameters": parameters, + } + + +def parse_policy_layers_json(data: bytes | bytearray | memoryview) -> tuple[dict[str, Any], ...]: + return parse_json_array(data) + + +def _resolve( + name: str, + schema: _ParamSchema, + constraints: tuple[PolicyConstraint, ...], + opt_out: bool, + exceptions: tuple[PolicyException, ...], +) -> ResolvedPolicyValue: + values = [item.value for item in constraints] + value = schema.default if not values else _merge(name, schema, values) + if schema.operator == "require" and opt_out and not any(item.value is True for item in constraints): + value = False + selected = _selected(constraints, value) + return ResolvedPolicyValue( + name, + jsonable(value), + schema.operator, + selected.source, + selected.precedence, + selected.policy_id, + selected.version, + opt_out, + bool(opt_out and value is False), + constraints, + exceptions, + ) + + +def _merge(name: str, schema: _ParamSchema, values: list[Any]) -> Any: + if schema.operator == "require": + if not all(isinstance(item, bool) for item in values): + fail(f"{name} require values must be boolean") + return any(values) + if schema.operator in {"minimum", "maximum"}: + nums = [finite_number(item, name) for item in values] + return max(nums) if schema.operator == "minimum" else min(nums) + if schema.operator in {"allowed_set", "selection"}: + return _merge_set_or_selection(name, schema, values) + if len({json.dumps(jsonable(item), sort_keys=True) for item in values}) != 1: + fail(f"{name} exact values conflict") + return values[0] + + +def _merge_set_or_selection(name: str, schema: _ParamSchema, values: list[Any]) -> Any: + allowed, selected = set(schema.allowed) if schema.allowed else None, _MISSING + for item in values: + candidate = set(item) if isinstance(item, tuple) else {item} + allowed = candidate if allowed is None else allowed & candidate + if not isinstance(item, tuple): + selected = item + if not allowed: + fail(f"{name} has an empty allowed-set intersection") + if schema.operator == "allowed_set": + return tuple(sorted(allowed, key=lambda item: "" if item is None else str(item))) + selection = schema.default if selected is _MISSING else selected + if selection not in allowed: + fail(f"{name} selection is outside the resolved allowed set") + return selection + + +def _parts(name: str, raw: Any, schema: _ParamSchema) -> tuple[list[Any], bool, list[dict[str, str]]]: + if not isinstance(raw, Mapping): + return [_normalize_value(raw, name, schema)], False, [] + item = closed(raw, {"value", "allowed", "opt_out", "exceptions"}, name, subset=True) + values = [] + if "opt_out" in item and not isinstance(item["opt_out"], bool): + fail(f"{name}.opt_out must be boolean") + if "allowed" in item: + values.append(set_value(item["allowed"], name)) + if "value" in item: + values.append(_normalize_value(item["value"], name, schema)) + exceptions = [_exception(exc, f"{name}.exceptions") for exc in item.get("exceptions", [])] + return values, bool(item.get("opt_out", False)), exceptions + + +def _normalize_value(value: Any, name: str, schema: _ParamSchema) -> Any: + if value is None: + return None + if schema.operator == "require" and not isinstance(value, bool): + fail(f"{name} must be boolean") + if schema.operator in {"minimum", "maximum"}: + return finite_number(value, name) + if schema.operator == "allowed_set": + return set_value(value, name) + value = normalize_json_value(value) if schema.operator in {"allowed_set", "selection", "exact"} else value + if schema.allowed and value not in schema.allowed: + fail(f"{name} has unsupported value {value!r}") + return value + + +def _parse_layers( + layers: Sequence[Mapping[str, Any]] | bytes | bytearray | memoryview, +) -> tuple[Mapping[str, Any], ...]: + if isinstance(layers, (bytes, bytearray, memoryview)): + return parse_json_array(layers) + if not isinstance(layers, Sequence) or isinstance(layers, str): + fail("policy layers must be an array") + return tuple(normalize_json_value(layer) for layer in layers) + + +def _source(value: Any, seen: set[str]) -> str: + source = token(value, "source") + if source not in PRECEDENCE: + fail(f"unknown policy source {source!r}") + if source in seen: + fail(f"duplicate policy source {source!r}") + seen.add(source) + return source + + +def _opt_out_source(source: str) -> str: + return "explicit_project_opt_out" if source == "project" else f"explicit_{source}_opt_out" + + +def _exception(value: Any, field: str) -> dict[str, str]: + item = closed(value, {"finding_code", "subject_digest"}, field, subset=True) + result = {"finding_code": token(item.get("finding_code"), f"{field}.finding_code")} + if "subject_digest" in item: + result["subject_digest"] = sha256(item["subject_digest"], f"{field}.subject_digest") + return result + + +def _selected(constraints: tuple[PolicyConstraint, ...], value: Any) -> PolicyConstraint: + default = PolicyConstraint("kernel", PRECEDENCE["kernel"], "figops-kernel-defaults", POLICY_VERSION, value) + if not constraints: + return default + matches = [item for item in constraints if item.value == value] + return sorted(matches or constraints, key=lambda item: item.precedence)[0] + + +def _version(value: Any) -> str: + value = token(value, "version") + if value != POLICY_VERSION: + fail("version has unsupported policy version") + return value diff --git a/hub_core/policy_resolution_json.py b/hub_core/policy_resolution_json.py new file mode 100644 index 0000000..c768992 --- /dev/null +++ b/hub_core/policy_resolution_json.py @@ -0,0 +1,115 @@ +"""Strict JSON and scalar helpers for policy resolution.""" + +from __future__ import annotations + +import json +import math +import re +import unicodedata +from collections.abc import Mapping +from typing import Any, NoReturn + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_PATH_RE = re.compile(r"^(?:[A-Za-z]:[\\/]|[\\/]{1,2}|~[\\/]|(?:file|runtime|raw):)", re.I) + + +class PolicyResolutionError(ValueError): + """Raised when policy inputs cannot resolve to one deterministic value.""" + + +def parse_json_array(data: bytes | bytearray | memoryview) -> tuple[dict[str, Any], ...]: + raw = bytes(data) + if raw.startswith(b"\xef\xbb\xbf"): + fail("JSON bytes must be UTF-8 without a BOM") + try: + parsed = json.loads(raw.decode("utf-8"), object_pairs_hook=_no_duplicates, parse_constant=_constant) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise PolicyResolutionError(f"policy JSON is invalid: {exc}") from exc + parsed = normalize_json_value(parsed) + if not isinstance(parsed, list): + fail("top-level policy JSON must be an array") + return tuple(parsed) + + +def closed(value: Any, allowed: set[str], field: str, *, subset: bool = False) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + fail(f"{field} must be a mapping") + keys = set(value) + if any(not isinstance(key, str) for key in keys) or keys - allowed or (allowed - keys and not subset): + fail(f"{field} contains unsupported or missing keys") + return value + + +def set_value(value: Any, field: str) -> tuple[Any, ...]: + if not isinstance(value, (list, tuple)) or not value: + fail(f"{field}.allowed must be a non-empty array") + normalized = tuple(normalize_json_value(item) for item in value) + if len(set(normalized)) != len(normalized): + fail(f"{field}.allowed contains duplicate values") + return normalized + + +def normalize_json_value(value: Any) -> Any: + if isinstance(value, str): + value = unicodedata.normalize("NFC", value) + if _PATH_RE.search(value) or "\\" in value or value.startswith("../"): + fail("policy input contains a path-like string") + return value + if isinstance(value, bool) or value is None or isinstance(value, int): + return value + if isinstance(value, float): + return value if math.isfinite(value) else fail("policy input contains non-finite number") + if isinstance(value, list): + return [normalize_json_value(item) for item in value] + if isinstance(value, tuple): + return tuple(normalize_json_value(item) for item in value) + if isinstance(value, Mapping): + result: dict[str, Any] = {} + for key, child in value.items(): + normalized = normalize_json_value(key) + if not isinstance(normalized, str) or normalized in result: + fail("policy object keys must be unique strings") + result[normalized] = normalize_json_value(child) + return result + fail("policy input contains unsupported JSON value") + + +def token(value: Any, field: str) -> str: + value = normalize_json_value(value) + if not isinstance(value, str) or not value.strip() or value != value.strip(): + fail(f"{field} must be a canonical non-empty string") + return value + + +def finite_number(value: Any, field: str) -> int | float: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + fail(f"{field} must be a finite number") + return value + + +def sha256(value: Any, field: str) -> str: + value = token(value, field) + if _SHA256_RE.fullmatch(value) is None: + fail(f"{field} must be a lowercase SHA-256 digest") + return value + + +def canonical_json_bytes(value: Mapping[str, Any]) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + + +def fail(message: str) -> NoReturn: + raise PolicyResolutionError(f"policy resolution {message}") + + +def _no_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise PolicyResolutionError(f"policy JSON contains duplicate key {key!r}") + result[key] = value + return result + + +def _constant(value: str) -> NoReturn: + raise PolicyResolutionError(f"policy JSON contains non-finite value {value}") diff --git a/hub_core/policy_resolution_types.py b/hub_core/policy_resolution_types.py new file mode 100644 index 0000000..e2b051c --- /dev/null +++ b/hub_core/policy_resolution_types.py @@ -0,0 +1,92 @@ +"""Immutable DTOs for canonical policy resolution.""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from typing import Any + +from .policy_resolution_json import canonical_json_bytes + + +@dataclass(frozen=True, slots=True) +class PolicyConstraint: + source: str + precedence: int + policy_id: str + version: str + value: Any + + +@dataclass(frozen=True, slots=True) +class PolicyException: + source: str + policy_id: str + version: str + finding_code: str + subject_digest: str | None = None + + +@dataclass(frozen=True, slots=True) +class ResolvedPolicyValue: + parameter: str + value: Any + merge_operator: str + source: str + precedence: int + policy_id: str + version: str + opt_out_requested: bool + opt_out_accepted: bool + constraints: tuple[PolicyConstraint, ...] + exceptions: tuple[PolicyException, ...] = () + + def to_json(self) -> dict[str, Any]: + return { + "value": jsonable(self.value), + "merge_operator": self.merge_operator, + "source": self.source, + "precedence": self.precedence, + "policy_id": self.policy_id, + "version": self.version, + "opt_out_requested": self.opt_out_requested, + "opt_out_accepted": self.opt_out_accepted, + "constraints": [_dc_json(item) for item in self.constraints], + "exceptions": [_dc_json(item) for item in self.exceptions], + } + + +@dataclass(frozen=True, slots=True) +class ResolvedPolicySet: + schema_version: str + parameters: tuple[ResolvedPolicyValue, ...] + + def value(self, parameter: str) -> ResolvedPolicyValue: + for item in self.parameters: + if item.parameter == parameter: + return item + raise KeyError(parameter) + + def to_json(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "parameters": {value.parameter: value.to_json() for value in self.parameters}, + } + + def canonical_bytes(self) -> bytes: + return canonical_json_bytes(self.to_json()) + + def canonical_sha256(self) -> str: + return hashlib.sha256(self.canonical_bytes()).hexdigest() + + +def jsonable(value: Any) -> Any: + return [jsonable(item) for item in value] if isinstance(value, tuple) else value + + +def _dc_json(value: PolicyConstraint | PolicyException) -> dict[str, Any]: + return { + key: jsonable(getattr(value, key)) + for key in value.__dataclass_fields__ + if getattr(value, key) is not None + } diff --git a/hub_core/project_normalization.py b/hub_core/project_normalization.py index 1f3eee3..3d32aea 100644 --- a/hub_core/project_normalization.py +++ b/hub_core/project_normalization.py @@ -10,6 +10,7 @@ from .adapters import select_adapters from .config_parser import ALLOWED_TARGET_FORMATS, load_yaml_with_unique_keys +from .dependency_script_inspection import analyze_dependency_script from .project_layout import SCAFFOLD_MANIFEST_FILENAME, build_scaffold_manifest from .project_structure_contract import resolve_project_structure from .structure_apply import apply_structure_plan @@ -41,6 +42,8 @@ NORMALIZATION_REVIEW_REQUIRED = "FIGOPS_NORMALIZATION_REVIEW_REQUIRED" NORMALIZATION_CONFIRMATION_REQUIRED = "FIGOPS_NORMALIZATION_CONFIRMATION_REQUIRED" NORMALIZATION_PLAN_REJECTED = "FIGOPS_NORMALIZATION_PLAN_REJECTED" +NORMALIZATION_HOST_APPROVAL_REQUIRED = "FIGOPS_NORMALIZATION_HOST_APPROVAL_REQUIRED" +NORMALIZATION_HOST_APPROVAL_REJECTED = "FIGOPS_NORMALIZATION_HOST_APPROVAL_REJECTED" def plan_scaffold_project( @@ -186,17 +189,41 @@ def plan_normalize_project( project_path = _project_root_path(project_path, must_exist_dir=True) if move_policy == "copy" and approved_mappings is None: raise ValueError("copy normalization requires explicit approved_mappings from a reviewed dry-run.") + if approved_mappings is not None and not isinstance(approved_mappings, list): + approved_mappings = list(approved_mappings) config, style_config_path = _load_project_config(project_path) proposed_mappings, unresolved_proposals = _propose_normalization_mappings( project_path, config=config, include_raw=include_raw, ) + approved_sources = { + source + for mapping in approved_mappings or [] + if isinstance(mapping, Mapping) + for source in [mapping.get("source")] + if isinstance(source, str) + } + unresolved_proposals = [ + proposal + for proposal in unresolved_proposals + if proposal.get("source") not in approved_sources + ] + dependency_blockers = _scan_normalization_script_dependencies( + project_path, + config=config, + proposed_mappings=proposed_mappings, + approved_mappings=approved_mappings, + ) plan = build_structure_plan( project_path, approved_mappings or [], config_diff=config_diff or [], - hardcoded_unresolved_references=hardcoded_unresolved_references or [], + hardcoded_unresolved_references=[ + *(hardcoded_unresolved_references or []), + *dependency_blockers, + ], + unresolved_proposals=unresolved_proposals, ) plan.update( { @@ -204,7 +231,6 @@ def plan_normalize_project( "adopt_existing": move_policy == "adopt", "include_raw": include_raw, "proposed_mappings": proposed_mappings, - "unresolved_proposals": unresolved_proposals, "style_summary": _style_summary(style_config_path), } ) @@ -218,6 +244,7 @@ def apply_normalize_project( hub_path: Path | None = None, overwrite: bool = False, confirmation_token: str | None = None, + pre_apply_verifier: Callable[[Path, Mapping[str, Any]], None] | None = None, post_apply_verifier: Callable[[Path, Mapping[str, Any]], Mapping[str, Any] | None] | None = None, ) -> dict[str, Any]: """Apply only an immutable copy plan with explicit confirmation.""" @@ -230,6 +257,7 @@ def apply_normalize_project( return apply_structure_plan( manifest, confirmation_token=confirmation_token, + pre_apply_verifier=pre_apply_verifier, post_apply_verifier=post_apply_verifier, ) @@ -246,6 +274,77 @@ def _iter_normalization_files(project_path: Path) -> list[Path]: return sorted(files, key=lambda item: item.relative_to(project_path).as_posix()) +def _scan_normalization_script_dependencies( + project_path: Path, + *, + config: Mapping[str, Any], + proposed_mappings: list[dict[str, Any]], + approved_mappings: list[dict[str, Any]] | None, +) -> list[dict[str, Any]]: + """Collect dependency blockers for the exact script sources under review. + + Static dependency evidence never becomes a role or mapping. In copy mode, + only explicitly reviewed script sources are inspected so an unrelated + legacy script cannot block an otherwise reviewed subset of a project. + Adopt-mode previews inspect the script candidates that discovery proposed. + """ + + if approved_mappings is not None: + script_sources = { + str(mapping["source"]) + for mapping in approved_mappings + if isinstance(mapping, Mapping) + and isinstance(mapping.get("source"), str) + and isinstance(mapping.get("role"), str) + and mapping["role"].startswith("script.") + } + else: + script_sources = { + str(mapping["source"]) + for mapping in proposed_mappings + if isinstance(mapping.get("source"), str) + and isinstance(mapping.get("role"), str) + and mapping["role"].startswith("script.") + } + if not script_sources: + return [] + + contract = resolve_project_structure(config, project_root=project_path) + role_roots = dict(contract.roots) + blockers: list[dict[str, Any]] = [] + for source in sorted(script_sources): + try: + script_path = _safe_destination(project_path, source) + result = analyze_dependency_script( + script_path, + script_path=source, + role_roots=role_roots, + ) + except (OSError, RuntimeError, TypeError, ValueError) as exc: + blockers.append( + { + "kind": "dependency_scan_incomplete", + "script": source, + "reason": f"dependency scan failed safely: {type(exc).__name__}", + } + ) + continue + + for reference in result.get("hardcoded_unresolved_references") or []: + blocker = dict(reference) if isinstance(reference, Mapping) else {"reference": str(reference)} + blocker["script"] = source + blockers.append(blocker) + if result.get("dependency_scan_incomplete"): + blockers.append( + { + "kind": "dependency_scan_incomplete", + "script": source, + "reason": "dependency scan could not inspect the complete script safely.", + } + ) + return blockers + + def _propose_normalization_mappings( project_path: Path, *, diff --git a/hub_core/promotion_gate.py b/hub_core/promotion_gate.py new file mode 100644 index 0000000..33fc16b --- /dev/null +++ b/hub_core/promotion_gate.py @@ -0,0 +1,666 @@ +"""Pure deterministic evaluator for publication promotion admission.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Final + +from .durable_receipt import DurableReceipt +from .human_review_receipt import ( + HumanReviewReceiptIndex, + HumanReviewVerificationPolicy, + build_review_subject, + verify_human_review_receipt, +) +from .policy_resolution_types import ResolvedPolicySet +from .promotion_gate_receipt import ( + SCHEMA_VERSION, + build_promotion_gate_receipt, + canonical_promotion_gate_json_bytes, + promotion_gate_digest, +) +from .publication_readiness import evidence_digest +from .workflow_intent import WorkflowIntent + +GateStatus = str +GateOutcome = str + +GATE_CODE_ORDER: Final = ( + "WORKFLOW_PROMOTION_ALLOWED", + "CANDIDATE_ARTIFACT_BOUND", + "RUNTIME_MANIFEST_ELIGIBLE", + "LINEAGE_RECEIPT_VALID", + "POLICY_RESOLUTION_VALID", + "PUBLICATION_READINESS_AUTOMATED", + "HUMAN_REVIEW_SIGNOFF", +) +GATE_CODE_PRECEDENCE: Final = {code: index for index, code in enumerate(GATE_CODE_ORDER)} +GATE_CODE_OWNERS: Final = { + "WORKFLOW_PROMOTION_ALLOWED": "hub_core.workflow_intent", + "CANDIDATE_ARTIFACT_BOUND": "hub_core.promotion_gate", + "RUNTIME_MANIFEST_ELIGIBLE": "hub_core.result_promotion", + "LINEAGE_RECEIPT_VALID": "hub_core.durable_receipt", + "POLICY_RESOLUTION_VALID": "hub_core.policy_resolution", + "PUBLICATION_READINESS_AUTOMATED": "hub_core.publication_readiness", + "HUMAN_REVIEW_SIGNOFF": "hub_core.human_review_receipt", +} +_SHA256_RE: Final = re.compile(r"^[0-9a-f]{64}$") +_OPAQUE_ID_RE: Final = re.compile(r"^(project|result\.figure):[0-9a-f]{32}$") +_PATH_LIKE_ID_RE: Final = re.compile( + r"^(?:[A-Za-z]:[\\/]|[\\/]{1,2}|~(?:[\\/]|$)|(?:file|https?|runtime|raw):)", + re.I, +) +_DESTINATION_RE: Final = re.compile(r"^(?!/)(?![A-Za-z]:)(?!.*(?:^|/)\.\.(?:/|$))[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$") + + +@dataclass(frozen=True, slots=True) +class _Gate: + code: str + outcome: GateOutcome + evidence_ref: str + message: str + + def as_dict(self) -> dict[str, str]: + return { + "code": self.code, + "outcome": self.outcome, + "evidence_ref": self.evidence_ref, + "message": self.message, + } + + +def _sha256(value: Any) -> str | None: + if isinstance(value, str) and _SHA256_RE.fullmatch(value): + return value + return None + + +def _digest_or_none(value: Mapping[str, Any] | None) -> str | None: + if value is None: + return None + try: + return promotion_gate_digest(value) + except Exception: + return None + + +def _opaque_id(value: Any, namespace: str) -> str | None: + if not isinstance(value, str) or _OPAQUE_ID_RE.fullmatch(value) is None: + return None + if value.partition(":")[0] != namespace: + return None + if _PATH_LIKE_ID_RE.search(value) or ".." in value or "\\" in value or "/" in value: + return None + return value + + +def _gate(code: str, outcome: GateOutcome, evidence_ref: str, message: str) -> _Gate: + if code not in GATE_CODE_PRECEDENCE: + raise ValueError(f"unknown promotion gate code: {code}") + return _Gate(code, outcome, evidence_ref, message) + + +def _workflow_gate(workflow_intent: WorkflowIntent | Mapping[str, Any]) -> _Gate: + if not isinstance(workflow_intent, WorkflowIntent): + return _gate( + "WORKFLOW_PROMOTION_ALLOWED", + "blocked", + "workflow_intent", + "Workflow intent must be a validated WorkflowIntent instance.", + ) + intent = workflow_intent.to_dict() + if intent.get("promotion_allowed") is True and intent.get("fail_closed") is False and intent.get("legacy") is False: + return _gate( + "WORKFLOW_PROMOTION_ALLOWED", + "passed", + "workflow_intent.promotion_allowed", + "Workflow intent allows a promotion admission decision.", + ) + return _gate( + "WORKFLOW_PROMOTION_ALLOWED", + "blocked", + "workflow_intent.promotion_allowed", + "Only an explicit non-legacy promotion workflow can enter the promotion gate.", + ) + + +def _candidate_gate(candidate_artifact: Mapping[str, Any]) -> tuple[_Gate, dict[str, str] | None]: + if not isinstance(candidate_artifact, Mapping): + return ( + _gate( + "CANDIDATE_ARTIFACT_BOUND", + "blocked", + "candidate_artifact", + "Candidate artifact evidence is missing or malformed.", + ), + None, + ) + required = {"project_id", "artifact_id", "role", "sha256"} + if not required <= set(candidate_artifact): + return ( + _gate( + "CANDIDATE_ARTIFACT_BOUND", + "blocked", + "candidate_artifact", + "Candidate artifact must declare project_id, artifact_id, role, and sha256.", + ), + None, + ) + digest = _sha256(candidate_artifact.get("sha256")) + project_id = _opaque_id(candidate_artifact.get("project_id"), "project") + artifact_id = _opaque_id(candidate_artifact.get("artifact_id"), "result.figure") + if candidate_artifact.get("role") != "result.figure" or digest is None or project_id is None or artifact_id is None: + return ( + _gate( + "CANDIDATE_ARTIFACT_BOUND", + "blocked", + "candidate_artifact", + "Candidate artifact must be a result.figure with opaque IDs and a lowercase SHA-256.", + ), + None, + ) + return ( + _gate( + "CANDIDATE_ARTIFACT_BOUND", + "passed", + "candidate_artifact", + "Candidate artifact has a stable result.figure identity and digest.", + ), + { + "project_id": project_id, + "artifact_id": artifact_id, + "artifact_sha256": digest, + }, + ) + + +def _primary_artifact_digest(manifest: Mapping[str, Any]) -> str | None: + evidence = manifest.get("evidence") + artifacts = evidence.get("artifacts") if isinstance(evidence, Mapping) else None + entries = artifacts.get("entries") if isinstance(artifacts, Mapping) else None + if not isinstance(entries, list): + return None + primary = [item for item in entries if isinstance(item, Mapping) and item.get("logical_role") == "primary"] + if len(primary) != 1: + return None + return _sha256(primary[0].get("sha256")) + + +def _runtime_manifest_gate( + runtime_manifest: Mapping[str, Any], + candidate: Mapping[str, str] | None, +) -> _Gate: + if not isinstance(runtime_manifest, Mapping): + return _gate( + "RUNTIME_MANIFEST_ELIGIBLE", + "blocked", + "runtime_manifest", + "Runtime manifest eligibility facts are missing or malformed.", + ) + eligible = ( + runtime_manifest.get("promotion_eligible") is True + and runtime_manifest.get("publication_status") == "verified" + and runtime_manifest.get("manual_review_needed") is False + ) + claim_inventory = runtime_manifest.get("claim_inventory") + claims = claim_inventory.get("claims") if isinstance(claim_inventory, Mapping) else None + claims_valid = ( + isinstance(claim_inventory, Mapping) + and claim_inventory.get("status") == "verified" + and claim_inventory.get("promotion_eligible") is True + and claim_inventory.get("manual_review_needed") is False + and claim_inventory.get("errors") in ([], ()) + and isinstance(claims, list) + and (bool(claims) or claim_inventory.get("explicit_no_claims") is True) + ) + primary_digest = _primary_artifact_digest(runtime_manifest) + matches_candidate = candidate is not None and primary_digest == candidate["artifact_sha256"] + if eligible and claims_valid and matches_candidate: + return _gate( + "RUNTIME_MANIFEST_ELIGIBLE", + "passed", + "runtime_manifest.promotion_eligible", + "Runtime manifest carries the existing machine eligibility facts.", + ) + return _gate( + "RUNTIME_MANIFEST_ELIGIBLE", + "blocked", + "runtime_manifest", + "Existing result-promotion eligibility, claim, or primary-artifact facts do not match.", + ) + + +def _lineage_gate( + durable_lineage_receipt: DurableReceipt | Mapping[str, Any] | None, + candidate: Mapping[str, str] | None, +) -> tuple[_Gate, str | None]: + if durable_lineage_receipt is None: + return ( + _gate( + "LINEAGE_RECEIPT_VALID", + "blocked", + "durable_lineage_receipt", + "Durable lineage receipt evidence is missing.", + ), + None, + ) + try: + receipt = ( + durable_lineage_receipt + if isinstance(durable_lineage_receipt, DurableReceipt) + else DurableReceipt.from_dict(durable_lineage_receipt) + ) + digest = receipt.canonical_sha256() + except Exception as exc: + return ( + _gate( + "LINEAGE_RECEIPT_VALID", + "blocked", + "durable_lineage_receipt", + f"Durable lineage receipt is invalid: {exc}", + ), + None, + ) + if ( + candidate is not None + and receipt.durable_artifact["role"] == "result.figure" + and receipt.durable_artifact["sha256"] == candidate["artifact_sha256"] + ): + return ( + _gate( + "LINEAGE_RECEIPT_VALID", + "passed", + "durable_lineage_receipt", + "Durable lineage receipt binds the candidate figure digest.", + ), + digest, + ) + return ( + _gate( + "LINEAGE_RECEIPT_VALID", + "blocked", + "durable_lineage_receipt.durable_artifact", + "Durable lineage receipt does not bind the candidate result.figure digest.", + ), + digest, + ) + + +def _policy_json( + resolved_policy: ResolvedPolicySet | Mapping[str, Any] | None, +) -> tuple[dict[str, Any] | None, str | None]: + if not isinstance(resolved_policy, ResolvedPolicySet): + return None, None + payload = resolved_policy.to_json() + return payload, resolved_policy.canonical_sha256() + + +def _policy_gate( + resolved_policy: ResolvedPolicySet | Mapping[str, Any] | None, +) -> tuple[_Gate, bool, str | None]: + payload, digest = _policy_json(resolved_policy) + if payload is None or digest is None: + return ( + _gate( + "POLICY_RESOLUTION_VALID", + "blocked", + "resolved_policy", + "Resolved policy must be a validated ResolvedPolicySet instance.", + ), + False, + None, + ) + parameters = payload.get("parameters") + signoff = parameters.get("human_signoff_required") if isinstance(parameters, Mapping) else None + signoff_value = signoff.get("value") if isinstance(signoff, Mapping) else None + if payload.get("schema_version") != "figops-resolved-policy-set/1" or not isinstance(signoff_value, bool): + return ( + _gate( + "POLICY_RESOLUTION_VALID", + "blocked", + "resolved_policy", + "Resolved policy must be a canonical policy-set projection with human_signoff_required.", + ), + False, + digest, + ) + return ( + _gate( + "POLICY_RESOLUTION_VALID", + "passed", + "resolved_policy", + "Resolved policy is canonical and signoff requirement is explicit.", + ), + signoff_value, + digest, + ) + + +def _readiness_gate(publication_readiness_report: Mapping[str, Any] | None) -> tuple[_Gate, str | None]: + if not isinstance(publication_readiness_report, Mapping): + return ( + _gate( + "PUBLICATION_READINESS_AUTOMATED", + "blocked", + "publication_readiness_report", + "Publication readiness report is missing or malformed.", + ), + None, + ) + try: + report_digest = promotion_gate_digest(dict(publication_readiness_report)) + except Exception: + report_digest = None + evidence = _sha256(publication_readiness_report.get("evidence_digest")) + status = publication_readiness_report.get("readiness_status") + if publication_readiness_report.get("schema_version") != "publication_readiness/1" or evidence is None: + return ( + _gate( + "PUBLICATION_READINESS_AUTOMATED", + "blocked", + "publication_readiness_report", + "Publication readiness report must expose schema_version and evidence_digest.", + ), + report_digest, + ) + if status == "blocked": + return ( + _gate( + "PUBLICATION_READINESS_AUTOMATED", + "blocked", + "publication_readiness_report.readiness_status", + "Required automated publication-readiness evidence is blocked.", + ), + report_digest, + ) + if status == "needs_revision": + return ( + _gate( + "PUBLICATION_READINESS_AUTOMATED", + "needs_revision", + "publication_readiness_report.readiness_status", + "Automated publication-readiness findings require revision.", + ), + report_digest, + ) + if status == "needs_review": + return ( + _gate( + "PUBLICATION_READINESS_AUTOMATED", + "passed", + "publication_readiness_report.readiness_status", + "Automated readiness evidence has reached the human-review boundary.", + ), + report_digest, + ) + return ( + _gate( + "PUBLICATION_READINESS_AUTOMATED", + "blocked", + "publication_readiness_report.readiness_status", + "Publication readiness status is outside the closed enum.", + ), + report_digest, + ) + + +def _review_gate( + *, + human_signoff_required: bool, + review_receipt: Mapping[str, Any] | bytes | bytearray | memoryview | None, + review_policy: HumanReviewVerificationPolicy | None, + now: datetime | str | None, + expected_subject: Mapping[str, str] | None, + receipt_index: HumanReviewReceiptIndex | None, +) -> tuple[_Gate, str | None]: + if not human_signoff_required: + return ( + _gate( + "HUMAN_REVIEW_SIGNOFF", + "passed", + "resolved_policy.parameters.human_signoff_required", + "Selected policy does not require a human signoff receipt.", + ), + None, + ) + if review_receipt is None: + return ( + _gate( + "HUMAN_REVIEW_SIGNOFF", + "needs_review", + "review_receipt", + "Selected policy requires a current affirmative human signoff receipt.", + ), + None, + ) + if not isinstance(review_policy, HumanReviewVerificationPolicy): + return ( + _gate( + "HUMAN_REVIEW_SIGNOFF", + "blocked", + "review_policy", + "Review receipt requires a validated HumanReviewVerificationPolicy instance.", + ), + None, + ) + try: + result = verify_human_review_receipt( + review_receipt, + policy=review_policy, + now=now, + expected_subject=expected_subject, + receipt_index=receipt_index, + require_approval=True, + ) + except Exception: + return ( + _gate( + "HUMAN_REVIEW_SIGNOFF", + "blocked", + "review_receipt", + "Human review receipt verification failed closed.", + ), + None, + ) + if result.valid: + return ( + _gate( + "HUMAN_REVIEW_SIGNOFF", + "passed", + "review_receipt", + "Human review receipt is current, affirmative, authorized, and subject-bound.", + ), + result.canonical_sha256, + ) + return ( + _gate( + "HUMAN_REVIEW_SIGNOFF", + "blocked", + "review_receipt", + f"Human review receipt is invalid: {result.reason}", + ), + result.canonical_sha256, + ) + + +def _status(gates: list[_Gate]) -> GateStatus: + outcomes = [gate.outcome for gate in gates] + if "blocked" in outcomes: + return "blocked" + if "needs_revision" in outcomes: + return "needs_revision" + if "needs_review" in outcomes: + return "needs_review" + return "eligible" + + +def _requested_destination(value: str | None) -> tuple[str | None, _Gate | None]: + if value is None: + return None, None + normalized = value.replace("\\", "/").strip() + if not normalized or _DESTINATION_RE.fullmatch(normalized) is None: + return normalized, _gate( + "CANDIDATE_ARTIFACT_BOUND", + "blocked", + "requested_destination", + "Requested destination must be a contained project-relative reference.", + ) + return normalized, None + + +def evaluate_promotion_gate( + *, + workflow_intent: WorkflowIntent | Mapping[str, Any], + candidate_artifact: Mapping[str, Any], + runtime_manifest: Mapping[str, Any], + durable_lineage_receipt: DurableReceipt | Mapping[str, Any] | None, + resolved_policy: ResolvedPolicySet | Mapping[str, Any] | None, + publication_readiness_report: Mapping[str, Any] | None, + review_receipt: Mapping[str, Any] | bytes | bytearray | memoryview | None = None, + review_policy: HumanReviewVerificationPolicy | None = None, + now: datetime | str | None = None, + receipt_index: HumanReviewReceiptIndex | None = None, + decision_scope: str = "figure_scientific_and_communication", + requested_destination: str | None = None, +) -> dict[str, Any]: + """Return a deterministic promotion-gate report and receipt candidate. + + This function performs no filesystem mutation and never invokes promotion. + Callers must supply already verified evidence objects. + """ + + destination, destination_gate = _requested_destination(requested_destination) + candidate_gate, candidate = _candidate_gate(candidate_artifact) + runtime_gate = _runtime_manifest_gate(runtime_manifest, candidate) + lineage_gate, lineage_digest = _lineage_gate(durable_lineage_receipt, candidate) + policy_gate, signoff_required, policy_digest = _policy_gate(resolved_policy) + readiness_gate, readiness_report_digest = _readiness_gate(publication_readiness_report) + evidence_sha = ( + _sha256(publication_readiness_report.get("evidence_digest")) + if isinstance(publication_readiness_report, Mapping) + else None + ) + subject = None + subject_binding_failed = False + if candidate is not None and lineage_digest is not None and evidence_sha is not None and policy_digest is not None: + try: + subject = build_review_subject( + project_id=candidate["project_id"], + artifact_id=candidate["artifact_id"], + artifact_sha256=candidate["artifact_sha256"], + lineage_receipt_sha256=lineage_digest, + evidence_digest=evidence_sha, + resolved_policy_digest=policy_digest, + decision_scope=decision_scope, + ) + except Exception: + subject = None + subject_binding_failed = True + if subject_binding_failed: + review_gate, review_digest = ( + _gate( + "HUMAN_REVIEW_SIGNOFF", + "blocked", + "review_subject", + "Review subject binding failed.", + ), + None, + ) + else: + review_gate, review_digest = _review_gate( + human_signoff_required=signoff_required, + review_receipt=review_receipt, + review_policy=review_policy, + now=now, + expected_subject=subject, + receipt_index=receipt_index, + ) + gates = [ + _workflow_gate(workflow_intent), + candidate_gate, + runtime_gate, + lineage_gate, + policy_gate, + readiness_gate, + review_gate, + ] + if destination_gate is not None: + gates[GATE_CODE_PRECEDENCE[destination_gate.code]] = destination_gate + gates.sort(key=lambda item: GATE_CODE_PRECEDENCE[item.code]) + gate_status = _status(gates) + gates_payload = [gate.as_dict() for gate in gates] + digests = { + "lineage_receipt_sha256": lineage_digest, + "publication_evidence_sha256": evidence_sha, + "resolved_policy_sha256": policy_digest, + "review_receipt_sha256": review_digest, + } + report_payload = { + "schema_version": SCHEMA_VERSION, + "gate_status": gate_status, + "subject": subject, + "digests": {**digests, "readiness_report_sha256": readiness_report_digest}, + "gates": gates_payload, + "requested_destination": destination, + } + report_sha = promotion_gate_digest(report_payload) + receipt_digests = { + "report_sha256": report_sha, + **digests, + } + receipt = build_promotion_gate_receipt( + gate_status=gate_status, + subject=subject, + digests=receipt_digests, + gates=gates_payload, + requested_destination=destination, + ) + return { + **report_payload, + "report_sha256": report_sha, + "receipt_candidate": receipt, + } + + +def evaluate_promotion_gate_from_evidence( + *, + publication_evidence: Mapping[str, Any], + **kwargs: Any, +) -> dict[str, Any]: + """Compatibility helper for callers that have normalized evidence but no report. + + It records the normalized evidence digest only; callers that need full + readiness findings should pass ``publication_readiness_report`` directly. + """ + + digest = evidence_digest(publication_evidence) + report = { + "schema_version": "publication_readiness/1", + "readiness_status": "needs_review", + "evidence_digest": digest, + "manual_review_required": True, + "gates": [], + "findings": [], + } + return evaluate_promotion_gate(publication_readiness_report=report, **kwargs) + + +def render_promotion_gate_json(report: Mapping[str, Any]) -> str: + """Render a byte-stable, human-readable JSON report.""" + + return canonical_promotion_gate_json_bytes(dict(report)).decode("utf-8") + "\n" + + +__all__ = [ + "GATE_CODE_ORDER", + "GATE_CODE_OWNERS", + "GATE_CODE_PRECEDENCE", + "GateOutcome", + "GateStatus", + "evaluate_promotion_gate", + "evaluate_promotion_gate_from_evidence", + "render_promotion_gate_json", +] diff --git a/hub_core/promotion_gate_receipt.py b/hub_core/promotion_gate_receipt.py new file mode 100644 index 0000000..ec716e3 --- /dev/null +++ b/hub_core/promotion_gate_receipt.py @@ -0,0 +1,316 @@ +"""Canonical receipt candidate for pure promotion-gate decisions.""" + +from __future__ import annotations + +import hashlib +import json +import math +import re +import unicodedata +from collections.abc import Mapping, Sequence +from typing import Any, Final + +SCHEMA_VERSION: Final = "figops-promotion-gate/1" +GATE_STATUSES: Final = frozenset({"blocked", "needs_revision", "needs_review", "eligible"}) +GATE_OUTCOMES: Final = frozenset({"passed", "blocked", "needs_revision", "needs_review"}) +GATE_CODE_ORDER: Final = ( + "WORKFLOW_PROMOTION_ALLOWED", + "CANDIDATE_ARTIFACT_BOUND", + "RUNTIME_MANIFEST_ELIGIBLE", + "LINEAGE_RECEIPT_VALID", + "POLICY_RESOLUTION_VALID", + "PUBLICATION_READINESS_AUTOMATED", + "HUMAN_REVIEW_SIGNOFF", +) +GATE_CODES: Final = frozenset(GATE_CODE_ORDER) +_RECEIPT_ID_RE: Final = re.compile(r"^promotion-gate:sha256:([0-9a-f]{64})$") +_SHA256_RE: Final = re.compile(r"^[0-9a-f]{64}$") +_OPAQUE_ID_RE: Final = re.compile(r"^(project|result\.figure):[0-9a-f]{32}$") +_PATH_LIKE_RE: Final = re.compile(r"^(?:[A-Za-z]:[\\/]|[\\/]{1,2}|~(?:[\\/]|$)|(?:file|https?|runtime|raw):)", re.I) +_TEXT_LEAK_RE: Final = re.compile( + r"(?:(? None: + raise PromotionGateReceiptError(f"promotion gate receipt {message}") + + +def _canonical_value(value: Any) -> Any: + if value is None or isinstance(value, bool) or isinstance(value, int): + return value + if isinstance(value, float): + if not math.isfinite(value): + _fail("contains a non-finite number") + return value + if isinstance(value, str): + normalized = unicodedata.normalize("NFC", value) + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in normalized): + _fail("contains a control character") + return normalized + if isinstance(value, Mapping): + result: dict[str, Any] = {} + for key, child in value.items(): + if not isinstance(key, str): + _fail("object keys must be strings") + normalized_key = unicodedata.normalize("NFC", key) + if normalized_key in result: + _fail("object keys must be unique after NFC normalization") + result[normalized_key] = _canonical_value(child) + return result + if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, memoryview, str)): + return [_canonical_value(item) for item in value] + _fail(f"contains unsupported JSON value {type(value).__name__}") + + +def canonical_promotion_gate_json_bytes(value: Mapping[str, Any]) -> bytes: + """Return the byte-stable JSON encoding used by gate reports and receipts.""" + + normalized = _canonical_value(value) + if not isinstance(normalized, Mapping): + _fail("canonical input must be a mapping") + return json.dumps( + normalized, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +def promotion_gate_digest(value: Mapping[str, Any]) -> str: + return hashlib.sha256(canonical_promotion_gate_json_bytes(value)).hexdigest() + + +def _closed(value: Any, allowed: set[str] | frozenset[str], field: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + _fail(f"{field} must be a mapping") + keys = set(value) + if any(not isinstance(key, str) for key in keys): + _fail(f"{field} keys must be strings") + if keys != allowed: + _fail(f"{field} contains missing or unsupported fields") + return value + + +def _validate_sha(value: Any, field: str) -> str: + if not isinstance(value, str) or _SHA256_RE.fullmatch(value) is None: + _fail(f"{field} must be a lowercase SHA-256") + return value + + +def _validate_opaque_id(value: Any, field: str, namespace: str) -> str: + if not isinstance(value, str) or _OPAQUE_ID_RE.fullmatch(value) is None: + _fail(f"{field} must be an opaque {namespace}:<128-bit-hex> identifier") + prefix = value.partition(":")[0] + if prefix != namespace: + _fail(f"{field} must be an opaque {namespace}:<128-bit-hex> identifier") + if _PATH_LIKE_RE.search(value) or ".." in value or "\\" in value or "/" in value: + _fail(f"{field} must not contain a path, URI, runtime/raw reference, or traversal") + return value + + +def _validate_safe_text(value: Any, field: str) -> str: + if not isinstance(value, str): + _fail(f"{field} must be a string") + if _TEXT_LEAK_RE.search(value) or ".." in value or "\\" in value: + _fail(f"{field} must not contain a path, URI, runtime/raw reference, or traversal") + lowered = value.casefold() + for marker in ("secret=", "password=", "api_key=", "apikey=", "token="): + if marker in lowered: + _fail(f"{field} must not contain secret-like material") + return value + + +def _validate_destination(value: Any) -> str | None: + if value is None: + return None + if not isinstance(value, str): + _fail("requested_destination must be a string or null") + if _PATH_LIKE_RE.search(value) or "\\" in value or _CONTAINED_DESTINATION_RE.fullmatch(value) is None: + _fail("requested_destination must be a contained project-relative token") + return value + + +def _derived_status(gates: list[dict[str, Any]]) -> str: + outcomes = [gate["outcome"] for gate in gates] + if "blocked" in outcomes: + return "blocked" + if "needs_revision" in outcomes: + return "needs_revision" + if "needs_review" in outcomes: + return "needs_review" + return "eligible" + + +def _validate_payload(value: Mapping[str, Any]) -> dict[str, Any]: + payload = _closed(_canonical_value(value), _PAYLOAD_FIELDS, "payload") + if payload["schema_version"] != SCHEMA_VERSION: + _fail(f"schema_version must be {SCHEMA_VERSION!r}") + gate_status = payload["gate_status"] + if gate_status not in GATE_STATUSES: + _fail("gate_status is outside the closed enum") + subject = payload["subject"] + if subject is not None: + subject = dict(_closed(subject, { + "project_id", + "artifact_id", + "artifact_sha256", + "lineage_receipt_sha256", + "evidence_digest", + "resolved_policy_digest", + "subject_digest", + }, "subject")) + for field in ( + "artifact_sha256", + "lineage_receipt_sha256", + "evidence_digest", + "resolved_policy_digest", + "subject_digest", + ): + _validate_sha(subject[field], f"subject.{field}") + subject["project_id"] = _validate_opaque_id(subject["project_id"], "subject.project_id", "project") + subject["artifact_id"] = _validate_opaque_id( + subject["artifact_id"], + "subject.artifact_id", + "result.figure", + ) + digests = dict(_closed(payload["digests"], { + "report_sha256", + "lineage_receipt_sha256", + "publication_evidence_sha256", + "resolved_policy_sha256", + "review_receipt_sha256", + }, "digests")) + for field, digest in digests.items(): + if digest is not None: + digests[field] = _validate_sha(digest, f"digests.{field}") + gates = payload["gates"] + if not isinstance(gates, list) or not gates: + _fail("gates must be a non-empty array") + normalized_gates: list[dict[str, Any]] = [] + seen_codes: set[str] = set() + for index, gate in enumerate(gates): + item = dict(_closed(gate, {"code", "outcome", "evidence_ref", "message"}, f"gates[{index}]")) + code = item["code"] + outcome = item["outcome"] + if code not in GATE_CODES: + _fail(f"gates[{index}].code is outside the closed enum") + if code in seen_codes: + _fail("gates contain duplicate codes") + seen_codes.add(code) + if outcome not in GATE_OUTCOMES: + _fail(f"gates[{index}].outcome is outside the closed enum") + for field in ("evidence_ref", "message"): + item[field] = _validate_safe_text(item[field], f"gates[{index}].{field}") + normalized_gates.append(item) + if seen_codes != GATE_CODES: + _fail("gates must contain exactly the closed promotion gate code set") + if [gate["code"] for gate in normalized_gates] != list(GATE_CODE_ORDER): + _fail("gates must use the canonical promotion gate order") + derived_status = _derived_status(normalized_gates) + if gate_status != derived_status: + _fail("gate_status must match the derived gate outcome precedence") + if gate_status == "eligible": + if subject is None: + _fail("eligible receipt requires a bound subject") + for field in ("lineage_receipt_sha256", "publication_evidence_sha256", "resolved_policy_sha256"): + if digests[field] is None: + _fail(f"eligible receipt requires digests.{field}") + if subject is not None: + digest_bindings = { + "lineage_receipt_sha256": "lineage_receipt_sha256", + "publication_evidence_sha256": "evidence_digest", + "resolved_policy_sha256": "resolved_policy_digest", + } + for digest_field, subject_field in digest_bindings.items(): + if digests[digest_field] is not None and subject[subject_field] != digests[digest_field]: + _fail(f"subject.{subject_field} must match digests.{digest_field}") + destination = _validate_destination(payload["requested_destination"]) + return { + "schema_version": SCHEMA_VERSION, + "gate_status": gate_status, + "subject": subject, + "digests": digests, + "gates": normalized_gates, + "requested_destination": destination, + } + + +def build_promotion_gate_receipt( + *, + gate_status: str, + subject: Mapping[str, Any] | None, + digests: Mapping[str, Any], + gates: Sequence[Mapping[str, Any]], + requested_destination: str | None = None, +) -> dict[str, Any]: + """Build a self-identifying canonical receipt candidate without persisting it.""" + + payload = _validate_payload( + { + "schema_version": SCHEMA_VERSION, + "gate_status": gate_status, + "subject": subject, + "digests": digests, + "gates": list(gates), + "requested_destination": requested_destination, + } + ) + payload_digest = promotion_gate_digest(payload) + return { + **payload, + "receipt_id": f"promotion-gate:sha256:{payload_digest}", + "integrity": {"canonical_sha256": payload_digest}, + } + + +def validate_promotion_gate_receipt(receipt: Mapping[str, Any]) -> dict[str, Any]: + normalized = _closed(_canonical_value(receipt), _RECEIPT_FIELDS, "receipt") + payload = _validate_payload({key: normalized[key] for key in _PAYLOAD_FIELDS}) + expected_digest = promotion_gate_digest(payload) + receipt_id = normalized["receipt_id"] + if not isinstance(receipt_id, str) or _RECEIPT_ID_RE.fullmatch(receipt_id) is None: + _fail("receipt_id must be promotion-gate:sha256:") + if receipt_id != f"promotion-gate:sha256:{expected_digest}": + _fail("receipt_id does not match canonical payload digest") + integrity = _closed(normalized["integrity"], {"canonical_sha256"}, "integrity") + if integrity["canonical_sha256"] != expected_digest: + _fail("integrity.canonical_sha256 does not match canonical payload digest") + return {**payload, "receipt_id": receipt_id, "integrity": {"canonical_sha256": expected_digest}} + + +def canonical_promotion_gate_receipt_bytes(receipt: Mapping[str, Any]) -> bytes: + return canonical_promotion_gate_json_bytes(validate_promotion_gate_receipt(receipt)) + + +__all__ = [ + "GATE_CODES", + "GATE_CODE_ORDER", + "GATE_OUTCOMES", + "GATE_STATUSES", + "PromotionGateReceiptError", + "SCHEMA_VERSION", + "build_promotion_gate_receipt", + "canonical_promotion_gate_json_bytes", + "canonical_promotion_gate_receipt_bytes", + "promotion_gate_digest", + "validate_promotion_gate_receipt", +] diff --git a/hub_core/promotion_gate_recording.py b/hub_core/promotion_gate_recording.py new file mode 100644 index 0000000..727b2c0 --- /dev/null +++ b/hub_core/promotion_gate_recording.py @@ -0,0 +1,189 @@ +"""Runtime-independent persistence for a validated promotion-gate receipt. + +The promotion gate is evaluated without side effects. The admission +transaction freezes its validated gate receipt below the project's evidence +role before invoking native durable result promotion, then removes that exact +receipt if promotion rejects a raced destination. It intentionally shares +the append-only/no-clobber path discipline with human review recording while +remaining independent of the durable promotion primitive. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath +from typing import Any, Mapping + +from .atomic_no_clobber import AtomicNoClobberUnavailable +from .promotion_gate_receipt import ( + canonical_promotion_gate_receipt_bytes, + validate_promotion_gate_receipt, +) +from .review_recording import ( + ReviewRecordExistsError, + _append_no_clobber, + _canonical_relative_path, + _prepare_evidence_root, + _reject_existing_destination, +) +from .structure_path_security import ( + capture_directory_witness, + capture_project_root, + delete_file_by_identity, + lease_directory_witness, +) + + +class PromotionGateRecordingError(RuntimeError): + """A validated promotion-gate receipt could not be frozen safely.""" + + +class PromotionGateRecordExistsError(PromotionGateRecordingError): + """The append-only promotion-gate destination already exists.""" + + +def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Reject duplicate keys while decoding persisted receipt JSON.""" + + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON object key") + result[key] = value + return result + + +def _reject_non_finite_json_constant(value: str) -> None: + raise ValueError(f"non-finite JSON constant {value!r}") + + +@dataclass(frozen=True, slots=True) +class PromotionGateRecordingResult: + """Runtime-independent identity of a frozen promotion-gate receipt.""" + + relative_path: str + receipt_id: str + canonical_sha256: str + size_bytes: int + # Private ownership witness used only for a failed-promotion rollback. It + # is excluded from repr/equality so the public result remains runtime + # independent while the transaction retains exact inode ownership. + _created_identity: tuple[int, int] | None = field(default=None, repr=False, compare=False) + + +def record_promotion_gate_receipt( + receipt: Mapping[str, Any], + *, + evidence_root: str | os.PathLike[str], + relative_path: str | os.PathLike[str], +) -> PromotionGateRecordingResult: + """Freeze one canonical gate receipt below ``evidence_root``. + + This function is append-only. It validates the closed gate DTO before + touching the destination and never replaces a competing inode. + """ + + try: + normalized = validate_promotion_gate_receipt(receipt) + payload = canonical_promotion_gate_receipt_bytes(normalized) + relative = _canonical_relative_path(relative_path) + root = _prepare_evidence_root(evidence_root) + root_identity = capture_project_root(root) + parent_relative = PurePosixPath(relative).parent.as_posix() + witness = capture_directory_witness(root, parent_relative, root_identity=root_identity, create=True) + destination = root.joinpath(*PurePosixPath(relative).parts) + with lease_directory_witness(witness): + _reject_existing_destination(destination) + _append_no_clobber(destination, payload) + metadata = destination.stat(follow_symlinks=False) + created_identity = (metadata.st_dev, metadata.st_ino) + except PromotionGateRecordingError: + raise + except ReviewRecordExistsError as exc: + raise PromotionGateRecordExistsError(str(exc)) from exc + except (TypeError, ValueError, RuntimeError, OSError, AtomicNoClobberUnavailable) as exc: + raise PromotionGateRecordingError(str(exc)) from exc + return PromotionGateRecordingResult( + relative_path=relative, + receipt_id=normalized["receipt_id"], + canonical_sha256=normalized["integrity"]["canonical_sha256"], + size_bytes=len(payload), + _created_identity=created_identity, + ) + + +def discard_promotion_gate_receipt( + result: PromotionGateRecordingResult, + *, + evidence_root: str | os.PathLike[str], +) -> None: + """Remove only the exact gate receipt created by this transaction. + + This is used when native durable promotion rejects a raced destination + after the gate receipt was frozen. A replacement inode is never removed. + """ + + if result._created_identity is None: + raise PromotionGateRecordingError("promotion gate receipt ownership witness is unavailable") + try: + root = Path(evidence_root).expanduser() + except (TypeError, ValueError) as exc: + raise PromotionGateRecordingError("promotion gate evidence root is unavailable") from exc + if not root.is_absolute() or root.is_symlink() or not root.is_dir(): + raise PromotionGateRecordingError("promotion gate evidence root changed before rollback") + relative = _canonical_relative_path(result.relative_path) + parent_relative = PurePosixPath(relative).parent.as_posix() + try: + root_identity = capture_project_root(root) + witness = capture_directory_witness(root, parent_relative, root_identity=root_identity, create=False) + destination = root.joinpath(*PurePosixPath(relative).parts) + with lease_directory_witness(witness): + metadata = destination.lstat() + if destination.is_symlink() or not destination.is_file(): + raise PromotionGateRecordingError("promotion gate receipt destination changed before rollback") + current = (metadata.st_dev, metadata.st_ino) + if current != result._created_identity: + raise PromotionGateRecordingError("promotion gate receipt ownership changed before rollback") + current_bytes = destination.read_bytes() + try: + parsed = json.loads( + current_bytes.decode("utf-8"), + object_pairs_hook=_reject_duplicate_json_keys, + parse_constant=_reject_non_finite_json_constant, + ) + if not isinstance(parsed, Mapping): + raise ValueError("receipt JSON root must be an object") + validated = validate_promotion_gate_receipt(parsed) + canonical_bytes = canonical_promotion_gate_receipt_bytes(validated) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as exc: + raise PromotionGateRecordingError( + "promotion gate receipt ownership changed before rollback" + ) from exc + if ( + canonical_bytes != current_bytes + or validated["integrity"]["canonical_sha256"] != result.canonical_sha256 + ): + raise PromotionGateRecordingError("promotion gate receipt ownership changed before rollback") + full_bytes_sha256 = hashlib.sha256(current_bytes).hexdigest() + if not delete_file_by_identity(destination, result._created_identity, full_bytes_sha256): + raise PromotionGateRecordingError( + "promotion gate receipt rollback ownership is ambiguous; receipt retained for review" + ) + except FileNotFoundError: + return + except PromotionGateRecordingError: + raise + except OSError as exc: + raise PromotionGateRecordingError("promotion gate receipt rollback failed") from exc + + +__all__ = [ + "PromotionGateRecordExistsError", + "PromotionGateRecordingError", + "PromotionGateRecordingResult", + "discard_promotion_gate_receipt", + "record_promotion_gate_receipt", +] diff --git a/hub_core/render_evidence.py b/hub_core/render_evidence.py index 42111ab..36ca6ba 100644 --- a/hub_core/render_evidence.py +++ b/hub_core/render_evidence.py @@ -28,6 +28,7 @@ _MAX_ARTIFACTS: Final = 256 _MAX_ARTIFACT_BYTES: Final = 256 * 1024 * 1024 _MAX_RASTER_PIXELS: Final = 100_000_000 +_RENDER_POLICY_CONTEXT_SCHEMA: Final = "figops-render-policy-context/1" class RenderEvidenceError(ValueError): @@ -42,6 +43,7 @@ def build_render_evidence( producer_version: str, resolved_policy: Mapping[str, Any] | None = None, render_policy: Mapping[str, Any] | None = None, + policy_context: Mapping[str, Any] | None = None, validation_target: str | None = None, baseline_reference_sha256: str | None = None, ) -> dict[str, Any]: @@ -74,8 +76,19 @@ def build_render_evidence( "visual_comparison": None, } policy = _resolved_policy(resolved_policy) + context = _policy_context(policy_context) selected_render_policy = _resolved_policy(render_policy) target = str(validation_target or "").strip().lower() + if context is not None: + evidence["policy_context"] = context + context_render_policy = _resolved_policy(context["render_policy"]) + if selected_render_policy is not None and selected_render_policy != context_render_policy: + raise RenderEvidenceError("render_policy conflicts with policy_context") + selected_render_policy = selected_render_policy or context_render_policy + context_target = str(context.get("validation_target") or "").strip().lower() + if target and context_target and target != context_target: + raise RenderEvidenceError("validation_target conflicts with policy_context") + target = target or context_target if target: if policy is not None: raise RenderEvidenceError( @@ -322,6 +335,32 @@ def _resolved_policy(raw: Mapping[str, Any] | None) -> dict[str, Any] | None: return policy +def _policy_context(raw: Mapping[str, Any] | None) -> dict[str, Any] | None: + if not isinstance(raw, Mapping): + return None + context = { + "schema_version": raw.get("schema_version"), + "source": raw.get("source"), + "policy_set_sha256": raw.get("policy_set_sha256"), + "render_policy": raw.get("render_policy"), + "validation_target": raw.get("validation_target"), + } + if context["schema_version"] != _RENDER_POLICY_CONTEXT_SCHEMA: + raise RenderEvidenceError("policy_context schema is malformed") + if not isinstance(context["source"], str) or not context["source"].strip(): + raise RenderEvidenceError("policy_context source is malformed") + if not isinstance(context["policy_set_sha256"], str) or _SHA256.fullmatch(context["policy_set_sha256"]) is None: + raise RenderEvidenceError("policy_context digest is malformed") + if not isinstance(context["render_policy"], Mapping): + raise RenderEvidenceError("policy_context render_policy is malformed") + if "validation_target" not in raw: + raise RenderEvidenceError("policy_context validation_target is malformed") + target = context["validation_target"] + if target is not None and (not isinstance(target, str) or not target.strip()): + raise RenderEvidenceError("policy_context validation_target is malformed") + return context + + def _detected_media_type(head: bytes) -> str: if head.startswith(b"\x89PNG\r\n\x1a\n"): return "image/png" diff --git a/hub_core/result_promotion.py b/hub_core/result_promotion.py index bde609f..66ae30a 100644 --- a/hub_core/result_promotion.py +++ b/hub_core/result_promotion.py @@ -10,7 +10,7 @@ import hashlib import json from pathlib import Path, PurePosixPath -from typing import Any, Mapping +from typing import Any, Mapping, TypeAlias from .artifact_policy_measurement import ( ArtifactPolicyMeasurementError, @@ -24,14 +24,86 @@ opaque_claim_id, opaque_receipt_id, ) +from .human_review_receipt import calculate_subject_digest, opaque_figure_artifact_id, opaque_project_id from .project_paths import project_path_has_symlink_component, resolve_project_output from .project_structure_contract import resolve_project_structure +from .promotion_gate_receipt import validate_promotion_gate_receipt +from .promotion_gate_recording import ( + PromotionGateRecordingError, + discard_promotion_gate_receipt, + record_promotion_gate_receipt, +) class ResultPromotionError(RuntimeError): """An eligible render could not be reduced to a safe durable result.""" +PromotionGateReceiptInput: TypeAlias = Mapping[str, Any] +_DEFAULT_PROMOTION_GATE_DECISION_SCOPE = "figure_scientific_and_communication" + + +def _verify_promotion_gate_admission( + promotion_gate_receipt: PromotionGateReceiptInput, + *, + primary_sha256: str, + durable_receipt: DurableReceipt, + expected_project_id: str, + expected_artifact_id: str, + decision_scope: str, +) -> dict[str, Any]: + """Require a canonical eligible gate bound to this result's lineage. + + The evaluator's report is accepted as a convenience envelope when it + contains ``receipt_candidate``; the receipt validator remains the sole + authority for the closed gate schema and derived status. + """ + + if not isinstance(promotion_gate_receipt, Mapping): + raise ResultPromotionError("promotion gate receipt/report must be a mapping") + candidate: Mapping[str, Any] = promotion_gate_receipt + report_status: object | None = None + if "receipt_candidate" in promotion_gate_receipt: + report_status = promotion_gate_receipt.get("gate_status") + candidate_value = promotion_gate_receipt.get("receipt_candidate") + if not isinstance(candidate_value, Mapping): + raise ResultPromotionError("promotion gate report receipt_candidate is malformed") + candidate = candidate_value + try: + validated = validate_promotion_gate_receipt(candidate) + except (TypeError, ValueError) as exc: + raise ResultPromotionError(f"promotion gate receipt is invalid: {exc}") from exc + if report_status is not None and report_status != validated.get("gate_status"): + raise ResultPromotionError("promotion gate report status does not match its receipt candidate") + if "receipt_candidate" in promotion_gate_receipt and report_status != "eligible": + raise ResultPromotionError("promotion gate report is not eligible") + if validated.get("gate_status") != "eligible": + raise ResultPromotionError("promotion gate receipt is not eligible") + subject = validated.get("subject") + if not isinstance(subject, Mapping): + raise ResultPromotionError("eligible promotion gate receipt is missing its subject") + if subject.get("project_id") != expected_project_id: + raise ResultPromotionError("promotion gate subject does not bind the trusted project identity") + if subject.get("artifact_id") != expected_artifact_id: + raise ResultPromotionError("promotion gate subject does not bind the selected figure identity") + if subject.get("artifact_sha256") != primary_sha256: + raise ResultPromotionError("promotion gate subject does not bind the verified primary artifact") + subject_payload = {key: value for key, value in subject.items() if key != "subject_digest"} + try: + expected_subject_digest = calculate_subject_digest( + subject_payload, + decision_scope, + ) + except (TypeError, ValueError) as exc: + raise ResultPromotionError("promotion gate subject digest is malformed") from exc + if subject.get("subject_digest") != expected_subject_digest: + raise ResultPromotionError("promotion gate subject digest does not match its subject") + expected_lineage_sha = durable_receipt.canonical_sha256() + if subject.get("lineage_receipt_sha256") != expected_lineage_sha: + raise ResultPromotionError("promotion gate subject does not bind the durable lineage receipt") + return validated + + def _sha256_text(value: object) -> str: text = str(value or "unavailable") if len(text) == 64: @@ -231,8 +303,17 @@ def promote_eligible_project_result( manifest_path: str | Path, figure_id: str, selected_figure: Mapping[str, Any], + promotion_gate_receipt: PromotionGateReceiptInput | None = None, + promotion_gate_decision_scope: str = _DEFAULT_PROMOTION_GATE_DECISION_SCOPE, ) -> tuple[PromotedArtifact, PromotedArtifact] | None: - """Promote one fully verified project render, or return ``None`` when gated.""" + """Promote one fully verified project render, or return ``None`` when gated. + + ``promotion_gate_receipt`` is an optional canonical receipt (or evaluator + report containing ``receipt_candidate``); when supplied, admission is + bound to the primary artifact and the durable receipt constructed here. + ``promotion_gate_decision_scope`` is required when the evaluator used a + non-default decision scope because the frozen gate receipt omits that field. + """ if not _is_promotion_eligible(manifest): return None @@ -305,6 +386,25 @@ def promote_eligible_project_result( manifest_sha256=hashlib.sha256(manifest_bytes).hexdigest(), publication_policy=publication_policy, ) + validated_gate_receipt: dict[str, Any] | None = None + if promotion_gate_receipt is not None: + project_config = config.get("project") if isinstance(config, Mapping) else None + project_name = project_config.get("name") if isinstance(project_config, Mapping) else None + if not isinstance(project_name, str) or not project_name.strip(): + raise ResultPromotionError("promotion gate requires a trusted project.name identity") + try: + expected_project_id = opaque_project_id(project_name) + expected_artifact_id = opaque_figure_artifact_id(str(figure_id)) + except (TypeError, ValueError) as exc: + raise ResultPromotionError("promotion gate subject identities are malformed") from exc + validated_gate_receipt = _verify_promotion_gate_admission( + promotion_gate_receipt, + primary_sha256=str(primary["sha256"]).lower(), + durable_receipt=receipt, + expected_project_id=expected_project_id, + expected_artifact_id=expected_artifact_id, + decision_scope=promotion_gate_decision_scope, + ) evidence_root = PurePosixPath(contract.roots["evidence"]) receipt_relpath = evidence_root / f"figure-{figure_key}.receipt.json" @@ -319,13 +419,46 @@ def promote_eligible_project_result( receipt_relpath.as_posix(), purpose="durable result receipt", ) - return promote_result_with_receipt( - runtime_artifact, - destination, - receipt, - receipt_destination, - runtime_root=runtime_root, - ) - - -__all__ = ["ResultPromotionError", "promote_eligible_project_result"] + gate_relpath = evidence_root / f"figure-{figure_key}.promotion-gate.json" + if validated_gate_receipt is not None: + try: + frozen_gate = record_promotion_gate_receipt( + validated_gate_receipt, + evidence_root=Path(project_root) / evidence_root, + relative_path=gate_relpath.relative_to(evidence_root).as_posix(), + ) + except Exception as exc: + raise ResultPromotionError( + f"promotion gate receipt could not be frozen: {exc}" + ) from exc + else: + frozen_gate = None + try: + promoted = promote_result_with_receipt( + runtime_artifact, + destination, + receipt, + receipt_destination, + runtime_root=runtime_root, + ) + except Exception as exc: + if frozen_gate is not None: + try: + discard_promotion_gate_receipt( + frozen_gate, + evidence_root=Path(project_root) / evidence_root, + ) + except PromotionGateRecordingError as cleanup_exc: + raise ResultPromotionError( + "promotion failed and promotion-gate receipt rollback was withheld: " + f"{cleanup_exc}" + ) from exc + raise + return promoted + + +__all__ = [ + "PromotionGateReceiptInput", + "ResultPromotionError", + "promote_eligible_project_result", +] diff --git a/hub_core/review_recording.py b/hub_core/review_recording.py new file mode 100644 index 0000000..e253c33 --- /dev/null +++ b/hub_core/review_recording.py @@ -0,0 +1,268 @@ +"""Storage-only recording of validated human-review receipts. + +This module deliberately does not issue or verify reviewer authority and does +not know about runtime renders or promotion. It accepts an already validated +receipt, serializes it canonically, and publishes those bytes once below a +caller-owned evidence root. +""" + +from __future__ import annotations + +import os +import secrets +import stat +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Mapping + +from .atomic_no_clobber import AtomicNoClobberUnavailable, atomic_no_clobber_move +from .human_review_receipt import ( + HumanReviewReceiptError, + canonical_human_review_receipt_bytes, + parse_human_review_receipt_bytes, + validate_human_review_receipt, +) +from .structure_path_security import ( + capture_directory_witness, + capture_project_root, + lease_directory_witness, +) + + +class ReviewRecordingError(RuntimeError): + """Raised when a review receipt cannot be recorded safely.""" + + +class ReviewRecordingAuthorizationError(ReviewRecordingError): + """Raised when the caller has not explicitly enabled this write.""" + + +class ReviewRecordExistsError(ReviewRecordingError): + """Raised when the append-only destination already exists.""" + + +@dataclass(frozen=True, slots=True) +class ReviewRecordingResult: + """Small, immutable, runtime-independent publication result.""" + + relative_path: str + receipt_id: str + canonical_sha256: str + size_bytes: int + + @property + def record_relative_path(self) -> str: + """Compatibility spelling for callers that call this a record path.""" + + return self.relative_path + + +_REPARSE_POINT = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + + +def record_human_review_receipt( + receipt_or_bytes: Mapping[str, Any] | bytes | bytearray | memoryview, + *, + evidence_root: str | os.PathLike[str], + relative_path: str | os.PathLike[str], + write_authorized: bool, +) -> ReviewRecordingResult: + """Publish one canonical human-review receipt below ``evidence_root``. + + ``write_authorized`` is an explicit caller-side write gate. A mapping is + schema-validated; byte input must already be the exact canonical encoding + emitted by :func:`canonical_human_review_receipt_bytes`. No existing + destination is replaced, including in a concurrent publication race. + """ + + if write_authorized is not True: + raise ReviewRecordingAuthorizationError("review recording writes are disabled") + + receipt, canonical_bytes = _validated_canonical_receipt(receipt_or_bytes) + relative = _canonical_relative_path(relative_path) + root = _prepare_evidence_root(evidence_root) + # The path-security helpers deliberately raise their own low-level + # ``RuntimeError``/``ValueError``/``OSError`` failures when a witnessed + # directory changes, becomes unsafe, or cannot be leased. Keep those + # checks fail-closed, but expose one public error type to callers of this + # storage API while preserving the helper's diagnostic message. + try: + root_identity = capture_project_root(root) + parent_relative = PurePosixPath(relative).parent.as_posix() + witness = capture_directory_witness( + root, + parent_relative, + root_identity=root_identity, + create=True, + ) + destination = root.joinpath(*PurePosixPath(relative).parts) + + with lease_directory_witness(witness): + _reject_existing_destination(destination) + _append_no_clobber(destination, canonical_bytes) + except ReviewRecordingError: + raise + except (RuntimeError, ValueError, OSError) as exc: + raise ReviewRecordingError(str(exc)) from exc + + # Keep the returned DTO independent of the absolute evidence-root path. + return ReviewRecordingResult( + relative_path=relative, + receipt_id=receipt["receipt_id"], + canonical_sha256=receipt["integrity"]["canonical_sha256"], + size_bytes=len(canonical_bytes), + ) + + +def _validated_canonical_receipt( + receipt_or_bytes: Mapping[str, Any] | bytes | bytearray | memoryview, +) -> tuple[dict[str, Any], bytes]: + try: + if isinstance(receipt_or_bytes, (bytes, bytearray, memoryview)): + raw = bytes(receipt_or_bytes) + receipt = parse_human_review_receipt_bytes(raw) + canonical = canonical_human_review_receipt_bytes(receipt) + if raw != canonical: + raise ReviewRecordingError("human review receipt bytes must be canonical") + elif isinstance(receipt_or_bytes, Mapping): + receipt = validate_human_review_receipt(receipt_or_bytes) + canonical = canonical_human_review_receipt_bytes(receipt) + else: + raise ReviewRecordingError("human review receipt must be a mapping or canonical bytes") + except ReviewRecordingError: + raise + except (HumanReviewReceiptError, TypeError, ValueError, AttributeError) as exc: + raise ReviewRecordingError(f"malformed human review receipt: {exc}") from exc + return receipt, canonical + + +def _canonical_relative_path(value: str | os.PathLike[str]) -> str: + try: + text = os.fspath(value) + except TypeError as exc: + raise ReviewRecordingError("review record destination must be a relative path") from exc + if not isinstance(text, str) or not text: + raise ReviewRecordingError("review record destination must be a non-empty relative path") + relative = PurePosixPath(text) + if ( + relative.is_absolute() + or relative.as_posix() != text + or not relative.parts + or any(part in {"", ".", ".."} for part in relative.parts) + or any(":" in part for part in relative.parts) + or "\\" in text + or any(ord(character) < 0x20 or ord(character) == 0x7F for character in text) + ): + raise ReviewRecordingError("review record destination must be canonical and evidence-root relative") + return relative.as_posix() + + +def _is_reparse_or_symlink(path: Path) -> bool: + try: + metadata = path.lstat() + except FileNotFoundError: + return False + return path.is_symlink() or bool(getattr(metadata, "st_file_attributes", 0) & _REPARSE_POINT) + + +def _prepare_evidence_root(value: str | os.PathLike[str]) -> Path: + try: + root = Path(value).expanduser() + except (TypeError, ValueError) as exc: + raise ReviewRecordingError("evidence root must be an absolute directory") from exc + if not root.is_absolute() or any(part in {".", ".."} for part in root.parts): + raise ReviewRecordingError("evidence root must be an absolute directory") + + # Bind/create every component without following a symlink or reparse point. + current = Path(root.anchor) + for part in root.parts[1:]: + current /= part + try: + exists = os.path.lexists(current) + except OSError as exc: + raise ReviewRecordingError("evidence root is unavailable") from exc + if exists: + if _is_reparse_or_symlink(current): + raise ReviewRecordingError("evidence root must not traverse a symlink or reparse point") + try: + if not current.is_dir(): + raise ReviewRecordingError("evidence root must be a directory") + except OSError as exc: + raise ReviewRecordingError("evidence root is unavailable") from exc + continue + try: + current.mkdir() + except FileExistsError: + pass + except OSError as exc: + raise ReviewRecordingError("evidence root is not writable") from exc + if _is_reparse_or_symlink(current) or not current.is_dir(): + raise ReviewRecordingError("evidence root must not traverse a symlink or reparse point") + try: + if _is_reparse_or_symlink(root) or not root.is_dir(): + raise ReviewRecordingError("evidence root must be a non-symlink directory") + except OSError as exc: + raise ReviewRecordingError("evidence root is unavailable") from exc + return root + + +def _reject_existing_destination(destination: Path) -> None: + try: + if not os.path.lexists(destination): + return + except OSError as exc: + raise ReviewRecordingError("review record destination is unavailable") from exc + if _is_reparse_or_symlink(destination): + raise ReviewRecordingError("review record destination must not be a symlink or reparse point") + raise ReviewRecordExistsError("review record destination already exists") + + +def _append_no_clobber(destination: Path, payload: bytes) -> None: + stage = destination.parent / f".{destination.name}.{os.getpid()}.{secrets.token_hex(12)}.tmp" + descriptor = -1 + try: + flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0) + descriptor = os.open(stage, flags, 0o600) + with os.fdopen(descriptor, "wb", closefd=True) as handle: + descriptor = -1 + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + try: + atomic_no_clobber_move(stage, destination) + except FileExistsError as exc: + raise ReviewRecordExistsError("review record destination already exists") from exc + except AtomicNoClobberUnavailable as exc: + raise ReviewRecordingError("review record publication is unavailable") from exc + except PermissionError as exc: + raise ReviewRecordingError("review record publication was denied") from exc + except OSError as exc: + raise ReviewRecordingError("review record publication failed") from exc + except ReviewRecordingError: + raise + except FileExistsError as exc: + # A pre-existing private stage name is not a publication race; retrying + # with a fresh random name is unnecessary and could hide tampering. + raise ReviewRecordingError("review record staging path already exists") from exc + except OSError as exc: + raise ReviewRecordingError("review record staging failed") from exc + finally: + if descriptor >= 0: + try: + os.close(descriptor) + except OSError: + pass + try: + if os.path.lexists(stage): + stage.unlink() + except OSError: + pass + + +__all__ = [ + "ReviewRecordExistsError", + "ReviewRecordingAuthorizationError", + "ReviewRecordingError", + "ReviewRecordingResult", + "record_human_review_receipt", +] diff --git a/hub_core/structure_apply.py b/hub_core/structure_apply.py index 0032458..680f52d 100644 --- a/hub_core/structure_apply.py +++ b/hub_core/structure_apply.py @@ -7,7 +7,7 @@ import shutil import stat import uuid -from pathlib import Path, PurePosixPath +from pathlib import Path from typing import Any, Callable, Mapping from .atomic_no_clobber import ( @@ -15,6 +15,10 @@ AtomicNoClobberUnavailable, atomic_no_clobber_move, ) +from .structure_apply_preflight import ( + materialize_parent_witness, + prepare_structure_entries, +) from .structure_path_security import ( DirectoryWitness, assert_directory_witness, @@ -52,27 +56,6 @@ def _sha256(path: Path) -> str: return digest.hexdigest() -def _inside(root: Path, value: object) -> Path: - if not isinstance(value, str): - raise ValueError("Plan paths must be strings.") - rel = PurePosixPath(value) - if ( - rel.is_absolute() - or rel.as_posix() != value - or ".." in rel.parts - or any(":" in part for part in rel.parts) - or "\\" in value - ): - raise ValueError("Plan path escapes the project root.") - path = root.joinpath(*rel.parts) - current = root - for part in rel.parts[:-1]: - current /= part - if current.is_symlink(): - raise ValueError(f"Plan path traverses a symlink: {value}") - return path - - def _fsync_parent(path: Path) -> None: if os.name == "nt": return @@ -502,6 +485,7 @@ def apply_structure_plan( plan: Mapping[str, Any], *, confirmation_token: str, + pre_apply_verifier: Callable[[Path, Mapping[str, Any]], None] | None = None, post_apply_verifier: Callable[[Path, Mapping[str, Any]], Mapping[str, Any] | None] | None = None, ) -> dict[str, Any]: """Apply a reviewed plan without moving, linking, replacing, or deleting originals.""" @@ -509,12 +493,19 @@ def apply_structure_plan( validate_confirmation_token(plan, confirmation_token) if plan.get("version") != PLAN_VERSION: raise ValueError(f"Unsupported structure plan version: {plan.get('version')!r}.") + for field in ("hardcoded_unresolved_references", "unresolved_proposals"): + if field not in plan: + raise ValueError(f"Structure plan is missing the required {field} field for its version.") + if not isinstance(plan[field], list): + raise ValueError(f"Structure plan {field} must be a list.") if plan.get("operation") != "copy_only": raise ValueError("Only copy_only structure plans can be applied.") if plan.get("collisions"): raise FileExistsError("Structure plan contains destination collisions.") if plan.get("hardcoded_unresolved_references"): raise RuntimeError("Structure plan has unresolved hard-coded dependencies.") + if plan.get("unresolved_proposals"): + raise RuntimeError("Structure plan has unresolved normalization proposals.") root = Path(str(plan.get("project_root"))).absolute() try: root_identity = capture_project_root(root) @@ -578,7 +569,9 @@ def apply_structure_plan( root_identity=root_identity, planned_hash=planned_config_hash if isinstance(planned_config_hash, str) else None, ) - prepared: list[tuple[Mapping[str, Any], Path, Path, DirectoryWitness]] = [] + # With no verifier, retain the historical eager parent preparation. When + # a verifier is supplied, destination-parent creation is deferred until + # after the final authority gate so rejection cannot mutate the namespace. created: list[tuple[Path, str, tuple[int, int]]] = [] config_replacement_identity: tuple[int, int] | None = None config_original_guard: Path | None = None @@ -587,37 +580,28 @@ def apply_structure_plan( held_directory_leases: list[Any] = [] config_replaced = False try: - for entry in entries: - if set(entry) != {"source", "destination", "role", "sha256", "size", "source_identity"}: - raise ValueError("Plan entry shape is invalid or contains an executable operation.") - if not isinstance(entry["source_identity"], Mapping) or set(entry["source_identity"]) != { - "device", - "inode", - }: - raise ValueError("Plan source identity is invalid.") - source = _inside(root, entry["source"]) - destination = _inside(root, entry["destination"]) - with open_bound_source( - root, - entry["source"], - root_identity=root_identity, - planned_identity=dict(entry["source_identity"]), - ) as source_handle: - source_hash, source_size = hash_handle(source_handle) - if source_size != entry["size"] or source_hash != entry["sha256"]: - raise RuntimeError(f"Planned source changed after review: {entry['source']}") - parent_relative = PurePosixPath(entry["destination"]).parent.as_posix() - parent_witness = capture_directory_witness( - root, - parent_relative, + prepared = prepare_structure_entries( + entries, + root=root, + root_identity=root_identity, + defer_parent_creation=pre_apply_verifier is not None, + ) + + # All reviewed-plan, root/config identity, source digest, destination + # collision, and containment checks are complete at this point. A + # host authority callback is deliberately the final gate before any + # staging or publication mutation begins. + if pre_apply_verifier is not None: + pre_apply_verifier(root, plan) + + for prepared_entry in prepared: + entry = prepared_entry.entry + destination = prepared_entry.destination + parent_witness = materialize_parent_witness( + prepared_entry, + root=root, root_identity=root_identity, - create=True, ) - if destination.exists() or destination.is_symlink(): - raise FileExistsError(f"Destination appeared after plan review: {entry['destination']}") - prepared.append((entry, source, destination, parent_witness)) - - for entry, _source, destination, parent_witness in prepared: with lease_directory_witness(parent_witness): assert_directory_witness(parent_witness) with open_bound_source( diff --git a/hub_core/structure_apply_preflight.py b/hub_core/structure_apply_preflight.py new file mode 100644 index 0000000..a5746a5 --- /dev/null +++ b/hub_core/structure_apply_preflight.py @@ -0,0 +1,137 @@ +"""Reviewed-plan destination preflight for structure transactions. + +This module owns plan validation and destination-parent witness preparation. +Destination parents are only created eagerly for the legacy no-verifier path; +when an authority verifier is supplied, existing parent components are +witnessed without mutation and any missing suffix is materialized only after +the verifier has accepted the reviewed plan. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Mapping, Sequence + +from .structure_path_security import ( + DirectoryWitness, + capture_directory_witness, + hash_handle, + open_bound_source, +) + + +@dataclass(frozen=True, slots=True) +class PreparedStructureEntry: + """Source and destination facts validated before the authority gate.""" + + entry: Mapping[str, Any] + source: Path + destination: Path + parent_relative: str + parent_witness: DirectoryWitness | None + + +def inside(root: Path, value: object) -> Path: + """Resolve a canonical project-relative plan path without following links.""" + + if not isinstance(value, str): + raise ValueError("Plan paths must be strings.") + relative = PurePosixPath(value) + if ( + relative.is_absolute() + or relative.as_posix() != value + or ".." in relative.parts + or any(":" in part for part in relative.parts) + or "\\" in value + ): + raise ValueError("Plan path escapes the project root.") + path = root.joinpath(*relative.parts) + current = root + for part in relative.parts[:-1]: + current /= part + if current.is_symlink(): + raise ValueError(f"Plan path traverses a symlink: {value}") + return path + + +def prepare_structure_entries( + entries: Sequence[Mapping[str, Any]], + *, + root: Path, + root_identity: tuple[int, int, int], + defer_parent_creation: bool, +) -> list[PreparedStructureEntry]: + """Validate sources, destinations, and parent witnesses before applying.""" + + prepared: list[PreparedStructureEntry] = [] + for entry in entries: + if set(entry) != {"source", "destination", "role", "sha256", "size", "source_identity"}: + raise ValueError("Plan entry shape is invalid or contains an executable operation.") + if not isinstance(entry["source_identity"], Mapping) or set(entry["source_identity"]) != { + "device", + "inode", + }: + raise ValueError("Plan source identity is invalid.") + source = inside(root, entry["source"]) + destination = inside(root, entry["destination"]) + with open_bound_source( + root, + entry["source"], + root_identity=root_identity, + planned_identity=dict(entry["source_identity"]), + ) as source_handle: + source_hash, source_size = hash_handle(source_handle) + if source_size != entry["size"] or source_hash != entry["sha256"]: + raise RuntimeError(f"Planned source changed after review: {entry['source']}") + parent_relative = PurePosixPath(entry["destination"]).parent.as_posix() + parent_witness: DirectoryWitness | None = None + if defer_parent_creation: + try: + # Validate existing parent components without creating a + # destination directory before the authority gate. + capture_directory_witness( + root, + parent_relative, + root_identity=root_identity, + create=False, + ) + except FileNotFoundError: + pass + else: + parent_witness = capture_directory_witness( + root, + parent_relative, + root_identity=root_identity, + create=True, + ) + if destination.exists() or destination.is_symlink(): + raise FileExistsError(f"Destination appeared after plan review: {entry['destination']}") + prepared.append( + PreparedStructureEntry( + entry=entry, + source=source, + destination=destination, + parent_relative=parent_relative, + parent_witness=parent_witness, + ) + ) + return prepared + + +def materialize_parent_witness( + prepared: PreparedStructureEntry, + *, + root: Path, + root_identity: tuple[int, int, int], +) -> DirectoryWitness: + """Return a reviewed parent witness, creating missing suffixes if needed.""" + + if prepared.parent_witness is not None: + return prepared.parent_witness + return capture_directory_witness( + root, + prepared.parent_relative, + root_identity=root_identity, + create=True, + ) diff --git a/hub_core/structure_audit_report.py b/hub_core/structure_audit_report.py new file mode 100644 index 0000000..20ebae9 --- /dev/null +++ b/hub_core/structure_audit_report.py @@ -0,0 +1,467 @@ +"""Deterministic, read-only structure audits across discovered projects. + +This module is intentionally a reporting layer: it never creates, edits, or +deletes project files. Discovery metadata is retained even when a project is +invalid or cannot pass the execution-path boundary, so an all-project audit +does not hide the very entries that need attention. +""" + +from __future__ import annotations + +import json +from collections import Counter +from pathlib import Path +from typing import Any, Mapping + +from .config_parser import load_config +from .execution_project_boundary import ExecutionProjectPathError, resolve_execution_project_path +from .project_discovery import discover_projects_with_status +from .structure_audit import audit_project_structure + +REPORT_SCHEMA_VERSION = "figops.project-structure-audit-report.v1" + + +def _error_text(exc: BaseException) -> str: + text = str(exc).strip() + return text or exc.__class__.__name__ + + +def _project_entry(project: Mapping[str, Any]) -> dict[str, Any]: + """Copy stable discovery fields while normalising list-valued diagnostics.""" + + raw_errors = project.get("errors") or [] + if isinstance(raw_errors, (str, bytes)): + raw_errors = [raw_errors] + return { + "project_id": str(project.get("project_id") or ""), + "name": str(project.get("name") or project.get("path") or ""), + "path": str(project.get("path") or ""), + "config": str(project.get("config") or ""), + "config_path": str(project.get("config_path") or ""), + "role": str(project.get("role") or ""), + "status": str(project.get("status") or ""), + "classification": str(project.get("classification") or ""), + "target_format": str(project.get("target_format") or ""), + "valid": bool(project.get("valid", False)), + "errors": [str(item) for item in raw_errors], + "audit_status": "pending", + "result_status": "pending", + "proposed_changes": [], + "audit": None, + } + + +def _append_project(projects: list[dict[str, Any]], entry: dict[str, Any]) -> None: + """Finalize compatibility fields before retaining one project row.""" + + entry["result_status"] = entry["audit_status"] + audit = entry.get("audit") + if isinstance(audit, Mapping): + # A report must remain diagnostic-only even if a future producer adds a + # plan field. Never leak mutation proposals through this surface. + entry["proposed_changes"] = [] + if isinstance(audit, dict): + audit["proposed_changes"] = [] + projects.append(entry) + + +def build_structure_audit_report( + root_dir: str | Path, + *, + max_depth: int = 4, + include_worktrees: bool = False, + include_ephemeral: bool = False, + include_quarantine: bool = False, +) -> dict[str, Any]: + """Audit every discovered project beneath *root_dir* in stable order. + + Discovery records are processed independently. A bad configuration is + reported as ``invalid``; a path rejected by the execution boundary is + reported as ``boundary_blocked``; config-less folder-role entries are kept + as ``skipped``. One failure therefore cannot make another project vanish + from the report. + """ + + root = Path(root_dir).expanduser().resolve() + depth = max(1, int(max_depth or 1)) + discovered = discover_projects_with_status( + root, + max_depth=depth, + include_worktrees=include_worktrees, + include_ephemeral=include_ephemeral, + include_quarantine=include_quarantine, + ) + # The discovery service normally sorts this already, but sorting here is a + # second deterministic boundary for callers supplying a custom service. + discovered = sorted( + (item for item in discovered if isinstance(item, Mapping)), + key=lambda item: ( + str(item.get("path") or "").casefold(), + str(item.get("project_id") or "").casefold(), + ), + ) + + projects: list[dict[str, Any]] = [] + for discovered_project in discovered: + entry = _project_entry(discovered_project) + relative_path = entry["path"] + try: + project_path = resolve_execution_project_path(root, relative_path) + except (ExecutionProjectPathError, OSError, RuntimeError, ValueError) as exc: + entry["audit_status"] = "boundary_blocked" + entry["errors"].append(_error_text(exc)) + _append_project(projects, entry) + continue + + if not entry["valid"]: + entry["audit_status"] = "invalid" + _append_project(projects, entry) + continue + + # Config-less folder-role entries are useful discovery evidence but do + # not contain enough information to construct a structure contract. + if not entry["config"]: + entry["audit_status"] = "skipped" + entry["errors"].append("project configuration was not discovered") + _append_project(projects, entry) + continue + + try: + loaded = load_config(project_path) + except Exception as exc: # keep one unreadable project from hiding others + entry["audit_status"] = "audit_error" + entry["errors"].append(_error_text(exc)) + _append_project(projects, entry) + continue + config = loaded[0] if isinstance(loaded, tuple) and loaded else None + if not isinstance(config, Mapping): + entry["audit_status"] = "invalid" + entry["errors"].append("project configuration could not be loaded") + _append_project(projects, entry) + continue + + try: + audit = audit_project_structure(project_path, config) + if not isinstance(audit, Mapping): + raise TypeError("structure audit returned a non-mapping result") + # Copy to detach the report from mutable producer dictionaries and + # keep this report strictly read-only. + audit_copy = dict(audit) + audit_copy["proposed_changes"] = [] + entry["audit"] = audit_copy + entry["audit_status"] = "audited" + except Exception as exc: # report the failure; never silently omit it + entry["audit_status"] = "audit_error" + entry["errors"].append(_error_text(exc)) + _append_project(projects, entry) + + status_counts = Counter(str(item["audit_status"]) for item in projects) + finding_count = 0 + unknown_count = 0 + finding_codes: Counter[str] = Counter() + for item in projects: + audit = item.get("audit") + if not isinstance(audit, Mapping): + continue + findings = audit.get("findings") + unknowns = audit.get("unknowns") + if isinstance(findings, list): + finding_count += len(findings) + for finding in findings: + if isinstance(finding, Mapping): + finding_codes[str(finding.get("code") or "unknown")] += 1 + if isinstance(unknowns, list): + unknown_count += len(unknowns) + + summary = { + "project_count": len(projects), + "discovered_count": len(projects), + "audited_count": status_counts.get("audited", 0), + "invalid_count": status_counts.get("invalid", 0), + "boundary_blocked_count": status_counts.get("boundary_blocked", 0), + "skipped_count": status_counts.get("skipped", 0), + "audit_error_count": status_counts.get("audit_error", 0), + "finding_count": finding_count, + "unknown_count": unknown_count, + "status_counts": dict(sorted(status_counts.items())), + "finding_counts": dict(sorted(finding_codes.items())), + } + return { + "schema_version": REPORT_SCHEMA_VERSION, + "root": str(root), + "max_depth": depth, + "projects": projects, + "proposed_changes": [], + "summary": summary, + } + + +def render_structure_audit_json(report: Mapping[str, Any]) -> str: + """Render a report as canonical, newline-terminated JSON.""" + + return json.dumps(dict(report), ensure_ascii=False, indent=2, sort_keys=True) + "\n" + + +def _md(value: Any) -> str: + return str(value if value is not None else "").replace("|", "\\|").replace("\n", " ") + + +def _project_sort_key(item: Mapping[str, Any]) -> tuple[str, str, str, str]: + """Return a stable key for report rows supplied by a caller. + + ``build_structure_audit_report`` already emits rows in this order, but the + renderer also accepts a report mapping from another producer. Sorting at + the rendering boundary keeps Markdown deterministic without mutating that + producer's data. + """ + + path = str(item.get("path") or "") + project_id = str(item.get("project_id") or "") + name = str(item.get("name") or "") + return (path.casefold(), path, project_id.casefold(), name.casefold()) + + +def _mapping_detail(value: Any, *, fallback: str) -> tuple[str, str]: + """Extract a path/reason pair from a diagnostic value without mutation.""" + + if isinstance(value, Mapping): + path = str(value.get("path") or value.get("name") or "") + reason = str(value.get("reason") or value.get("message") or "") + if not reason: + reason = fallback + return path, reason + text = str(value if value is not None else "").strip() + return "", text or fallback + + +def _unknown_rows(projects: list[Any]) -> list[tuple[str, str, str, str, str]]: + """Flatten unknown diagnostics into deterministic render-only rows. + + The final tuple contains project path, unknown path, candidate role, + reason, and project id. Keeping this as a separate flattened view makes + it straightforward to enumerate every unknown while leaving the JSON + report untouched. + """ + + rows: list[tuple[str, str, str, str, str]] = [] + for item in projects: + if not isinstance(item, Mapping): + continue + project_path = str(item.get("path") or item.get("name") or "") + project_id = str(item.get("project_id") or "") + audit = item.get("audit") if isinstance(item.get("audit"), Mapping) else {} + unknowns = audit.get("unknowns") if isinstance(audit.get("unknowns"), list) else [] + for unknown in unknowns: + unknown_path, reason = _mapping_detail(unknown, fallback="no reason provided") + candidate_role = "" + if isinstance(unknown, Mapping): + candidate = unknown.get("candidate") + if isinstance(candidate, Mapping): + candidate_role = str(candidate.get("candidate_role") or "") + candidate_reason = str(candidate.get("reason") or "") + if candidate_reason: + reason = candidate_reason + elif candidate is not None: + candidate_role = str(candidate) + rows.append((project_path, unknown_path, candidate_role, reason, project_id)) + return sorted( + rows, + key=lambda row: ( + row[0].casefold(), + row[1].casefold(), + row[2].casefold(), + row[3].casefold(), + row[4].casefold(), + ), + ) + + +def _audit_error_rows(projects: list[Any]) -> list[tuple[str, str, str, str]]: + """Flatten project-level audit errors into deterministic render-only rows.""" + + rows: list[tuple[str, str, str, str]] = [] + for item in projects: + if not isinstance(item, Mapping): + continue + project_path = str(item.get("path") or item.get("name") or "") + project_id = str(item.get("project_id") or "") + errors = item.get("errors") + if isinstance(errors, (str, bytes)): + errors = [errors] + if not isinstance(errors, list): + continue + for error in errors: + error_path, reason = _mapping_detail(error, fallback="unspecified audit error") + rows.append((error_path or project_path, reason, project_path, project_id)) + return sorted( + rows, + key=lambda row: ( + row[2].casefold(), + row[2], + row[0].casefold(), + row[0], + row[1].casefold(), + row[3].casefold(), + ), + ) + + +def render_structure_audit_markdown(report: Mapping[str, Any]) -> str: + """Render a compact deterministic Markdown report.""" + + summary = report.get("summary") if isinstance(report, Mapping) else {} + summary = summary if isinstance(summary, Mapping) else {} + projects = report.get("projects") if isinstance(report, Mapping) else [] + projects = projects if isinstance(projects, list) else [] + lines = [ + "# Project Structure Audit", + "", + f"Root: `{_md(report.get('root', ''))}`", + f"Max depth: `{_md(report.get('max_depth', ''))}`", + "", + "## Summary", + "", + "| Metric | Count |", + "| --- | ---: |", + ] + for key in ( + "project_count", + "audited_count", + "invalid_count", + "boundary_blocked_count", + "skipped_count", + "audit_error_count", + "finding_count", + "unknown_count", + ): + lines.append(f"| {_md(key.replace('_', ' '))} | {_md(summary.get(key, 0))} |") + ordered_projects = sorted( + (item for item in projects if isinstance(item, Mapping)), + key=_project_sort_key, + ) + lines.extend( + [ + "", + "## Projects", + "", + "| Path | Name | Role | Lifecycle | Audit status | Findings | Unknowns | Errors |", + "| --- | --- | --- | --- | ---: | ---: | ---: | --- |", + ] + ) + for item in ordered_projects: + audit = item.get("audit") if isinstance(item.get("audit"), Mapping) else {} + findings = audit.get("findings") if isinstance(audit.get("findings"), list) else [] + unknowns = audit.get("unknowns") if isinstance(audit.get("unknowns"), list) else [] + lines.append( + "| " + + " | ".join( + [ + _md(item.get("path", "")), + _md(item.get("name", "")), + _md(item.get("role", "")), + _md(item.get("status", "")), + _md(item.get("audit_status", "")), + str(len(findings)), + str(len(unknowns)), + _md("; ".join(str(error) for error in (item.get("errors") or []))), + ] + ) + + " |" + ) + lines.extend(["", "## Findings", ""]) + any_findings = False + for item in ordered_projects: + audit = item.get("audit") if isinstance(item.get("audit"), Mapping) else {} + findings = audit.get("findings") if isinstance(audit.get("findings"), list) else [] + if findings: + any_findings = True + lines.append(f"### `{_md(item.get('path', ''))}`") + for finding in findings: + if isinstance(finding, Mapping): + code = _md(finding.get("code", "finding")) + detail = ", ".join( + f"{_md(k)}={_md(v)}" for k, v in sorted(finding.items()) if k != "code" + ) + lines.append(f"- `{code}`" + (f": {detail}" if detail else "")) + else: + lines.append(f"- {_md(finding)}") + lines.append("") + if not any_findings: + lines.append("No structure findings.") + + lines.extend(["", "## Unknowns", ""]) + unknown_rows = _unknown_rows(ordered_projects) + if unknown_rows: + current_project = None + for project_path, unknown_path, candidate_role, reason, _project_id in unknown_rows: + if project_path != current_project: + if current_project is not None: + lines.append("") + lines.append(f"### `{_md(project_path)}`") + current_project = project_path + detail = [] + if candidate_role: + detail.append(f"candidate role `{_md(candidate_role)}`") + detail.append(f"reason: {_md(reason)}") + lines.append(f"- `{_md(unknown_path or '(unspecified path)')}`: " + "; ".join(detail)) + else: + lines.append("No unknown paths.") + + lines.extend(["", "## Audit Errors", ""]) + error_rows = _audit_error_rows(ordered_projects) + if error_rows: + current_project = None + for error_path, reason, project_path, _project_id in error_rows: + if project_path != current_project: + if current_project is not None: + lines.append("") + lines.append(f"### `{_md(project_path)}`") + current_project = project_path + lines.append(f"- `{_md(error_path or '(project path unavailable)')}`: {_md(reason)}") + else: + lines.append("No audit errors.") + return "\n".join(lines).rstrip() + "\n" + + +def render_structure_audit_report(report: Mapping[str, Any], *, output_format: str = "markdown") -> str: + """Render *report* as ``markdown`` (default) or ``json``.""" + + selected = str(output_format or "markdown").strip().lower() + if selected == "json": + return render_structure_audit_json(report) + if selected in {"markdown", "md"}: + return render_structure_audit_markdown(report) + raise ValueError("output_format must be 'json' or 'markdown'") + + +# Compatibility names used by early callers of the all-project read-only +# surface. They intentionally return the same canonical report envelope. +def audit_discovered_projects( + root_dir: str | Path, + *, + max_depth: int = 4, + include_worktrees: bool = False, + include_ephemeral: bool = False, + include_quarantine: bool = False, +) -> dict[str, Any]: + return build_structure_audit_report( + root_dir, + max_depth=max_depth, + include_worktrees=include_worktrees, + include_ephemeral=include_ephemeral, + include_quarantine=include_quarantine, + ) + + +audit_all_projects = audit_discovered_projects + + +__all__ = [ + "REPORT_SCHEMA_VERSION", + "build_structure_audit_report", + "audit_discovered_projects", + "audit_all_projects", + "render_structure_audit_json", + "render_structure_audit_markdown", + "render_structure_audit_report", +] diff --git a/hub_core/structure_inventory.py b/hub_core/structure_inventory.py index c86a99f..fd351ea 100644 --- a/hub_core/structure_inventory.py +++ b/hub_core/structure_inventory.py @@ -18,6 +18,28 @@ {".csv", ".tsv", ".txt", ".parquet", ".json", ".xlsx", ".xls", ".h5", ".hdf5", ".feather"} ) _FIGURE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".svg", ".pdf", ".eps", ".tif", ".tiff"}) +_PATH_REFERENCE_KEYS = frozenset( + { + "asset", + "assets", + "file", + "files", + "input", + "inputs", + "lock", + "locks", + "manifest", + "manifests", + "output", + "outputs", + "path", + "paths", + "script", + "scripts", + "source", + "sources", + } +) def _relative_path(value: object) -> str | None: @@ -29,6 +51,21 @@ def _relative_path(value: object) -> str | None: return path.as_posix() +def _is_configured_path(trail: tuple[str, ...]) -> bool: + """Return whether the scalar sits under a path-bearing config key. + + Config metadata contains many dotted strings (schema versions, helper + module names, and prose descriptions) that look like paths to a generic + suffix check. Explicit path-bearing keys remain the authoritative signal, + including lock-file keys such as ``environment.python_lock``. + """ + + return any( + (key := part.lower()) in _PATH_REFERENCE_KEYS or key.endswith("_lock") + for part in trail + ) + + def _walk_references(value: object, trail: tuple[str, ...] = ()) -> Iterable[tuple[tuple[str, ...], str]]: if isinstance(value, Mapping): for key in sorted(value, key=str): @@ -38,7 +75,7 @@ def _walk_references(value: object, trail: tuple[str, ...] = ()) -> Iterable[tup yield from _walk_references(item, (*trail, str(index))) else: path = _relative_path(value) - if path is not None and ("/" in path or PurePosixPath(path).suffix): + if path is not None and _is_configured_path(trail): yield trail, path diff --git a/hub_core/structure_plan.py b/hub_core/structure_plan.py index eab393d..34fe85c 100644 --- a/hub_core/structure_plan.py +++ b/hub_core/structure_plan.py @@ -63,6 +63,7 @@ def build_structure_plan( *, config_diff: Iterable[Mapping[str, Any]] = (), hardcoded_unresolved_references: Iterable[object] = (), + unresolved_proposals: Iterable[object] = (), ) -> dict[str, Any]: """Create a deterministic plan solely from explicitly approved mappings.""" @@ -124,6 +125,13 @@ def build_structure_plan( ], key=lambda item: json.dumps(item, ensure_ascii=False, sort_keys=True), ) + normalized_unresolved_proposals = sorted( + [ + dict(item) if isinstance(item, Mapping) else {"proposal": str(item)} + for item in unresolved_proposals + ], + key=lambda item: json.dumps(item, ensure_ascii=False, sort_keys=True), + ) config_path = root / "project_config.yaml" config_sha256: str | None = None config_identity: dict[str, int] | None = None @@ -162,6 +170,7 @@ def build_structure_plan( "config_identity": config_identity, "config_update": config_update, "hardcoded_unresolved_references": unresolved, + "unresolved_proposals": normalized_unresolved_proposals, "total_bytes": sum(item["size"] for item in entries), "rollback_journal": rollback, } diff --git a/hub_core/templates/project_config_template.yaml b/hub_core/templates/project_config_template.yaml index 870b4b3..dc2b620 100644 --- a/hub_core/templates/project_config_template.yaml +++ b/hub_core/templates/project_config_template.yaml @@ -36,6 +36,10 @@ project: version: "1.0.0" target_journal: "Nature Communications" +# 1.1 Workflow intent +workflow: + intent: "execution" # exploration | execution | review | promotion + # 2. Visual style settings visual_style: render_policy: "neutral" # preserves authored styling; independent of validation diff --git a/hub_core/workflow_intent.py b/hub_core/workflow_intent.py new file mode 100644 index 0000000..d516e4a --- /dev/null +++ b/hub_core/workflow_intent.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Any, Mapping + +INTENT_EXPLORATION = "exploration" +INTENT_EXECUTION = "execution" +INTENT_REVIEW = "review" +INTENT_PROMOTION = "promotion" +WORKFLOW_INTENTS = (INTENT_EXPLORATION, INTENT_EXECUTION, INTENT_REVIEW, INTENT_PROMOTION) + +SOURCE_EXPLICIT = "explicit" +SOURCE_ORCHESTRATOR = "orchestrator" +SOURCE_MCP = "mcp" +SOURCE_DIRECT_CSV = "direct_csv" +SOURCE_READ_ONLY = "read_only" +SOURCE_READINESS = "readiness" +SOURCE_LEGACY = "legacy" +WORKFLOW_SOURCES = ( + SOURCE_EXPLICIT, SOURCE_ORCHESTRATOR, SOURCE_MCP, SOURCE_DIRECT_CSV, + SOURCE_READ_ONLY, SOURCE_READINESS, SOURCE_LEGACY, +) + +ORCHESTRATOR_EXECUTION_STEPS = frozenset({"all", "analysis", "plot"}) +MCP_EXECUTION_TOOLS = frozenset({"figops.render_project_script", "figops.render_project_figure"}) +DIRECT_CSV_TOOLS = frozenset({"figops.render_basic_csv", "figops.render_csv_graph", "figops.render_csv_multipanel"}) +READINESS_TOOLS = frozenset({"figops.evaluate_publication_readiness"}) +READ_ONLY_TOOLS = frozenset( + { + "figops.health", "figops.describe", "figops.list_styles", "figops.list_projects", + "figops.inspect_project", "figops.validate_project", "figops.collect_artifacts", + "figops.inspect_data", "figops.audit_artifact", + } +) +EXECUTION_INTENTS = frozenset({INTENT_EXECUTION}) +EXPLORATION_REVIEW_INTENTS = frozenset({INTENT_EXPLORATION, INTENT_REVIEW}) +REVIEW_INTENTS = frozenset({INTENT_REVIEW}) +ORCHESTRATOR_SOURCES = frozenset({SOURCE_EXPLICIT, SOURCE_ORCHESTRATOR}) +MCP_SOURCES = frozenset({SOURCE_EXPLICIT, SOURCE_MCP}) +DIRECT_CSV_SOURCES = frozenset({SOURCE_EXPLICIT, SOURCE_DIRECT_CSV}) +READ_ONLY_SOURCES = frozenset({SOURCE_EXPLICIT, SOURCE_READINESS, SOURCE_READ_ONLY}) +SURFACE_RULES = MappingProxyType( + { + SOURCE_ORCHESTRATOR: (INTENT_EXECUTION, EXECUTION_INTENTS, ORCHESTRATOR_SOURCES), + SOURCE_MCP: (INTENT_EXECUTION, EXECUTION_INTENTS, MCP_SOURCES), + SOURCE_DIRECT_CSV: (INTENT_EXPLORATION, EXPLORATION_REVIEW_INTENTS, DIRECT_CSV_SOURCES), + SOURCE_READINESS: (INTENT_REVIEW, REVIEW_INTENTS, READ_ONLY_SOURCES), + SOURCE_READ_ONLY: (INTENT_REVIEW, REVIEW_INTENTS, READ_ONLY_SOURCES), + } +) + + +class WorkflowIntentError(ValueError): + """A workflow intent or provenance source is outside the closed vocabulary.""" + + +def _normalize_token(value: object, *, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise WorkflowIntentError(f"{field_name} must be a non-empty string") + return value.strip().lower().replace("-", "_").replace(" ", "_") + + +def parse_workflow_intent(value: object) -> str: + normalized = _normalize_token(value, field_name="workflow intent") + if normalized not in WORKFLOW_INTENTS: + raise WorkflowIntentError(f"unknown workflow intent: {value!r}") + return normalized + + +def parse_workflow_source(value: object) -> str: + normalized = _normalize_token(value, field_name="workflow source") + if normalized not in WORKFLOW_SOURCES: + raise WorkflowIntentError(f"unknown workflow source: {value!r}") + return normalized + + +def _tool_name(value: object) -> str: + return str(value or "").strip() + + +def _legacy_surface(tool_name: str, project_status: object, source: str | None) -> bool: + status = str(project_status or "").strip().lower() + return status == "legacy" or source == SOURCE_LEGACY or tool_name.startswith("graphhub.") + + +@dataclass(frozen=True, slots=True) +class WorkflowIntent: + intent: str | None + source: str | None + provenance: Mapping[str, Any] = field(default_factory=dict) + fail_closed: bool = False + legacy: bool = False + issues: tuple[str, ...] = () + + def __post_init__(self) -> None: + if self.intent is not None and self.intent not in WORKFLOW_INTENTS: + raise WorkflowIntentError(f"unknown workflow intent: {self.intent!r}") + if self.source is not None and self.source not in WORKFLOW_SOURCES: + raise WorkflowIntentError(f"unknown workflow source: {self.source!r}") + object.__setattr__(self, "provenance", MappingProxyType(dict(self.provenance))) + object.__setattr__(self, "issues", tuple(self.issues)) + + @property + def execution_allowed(self) -> bool: + return self.intent == INTENT_EXECUTION and not self.fail_closed and not self.legacy + + @property + def promotion_allowed(self) -> bool: + return self.intent == INTENT_PROMOTION and not self.fail_closed and not self.legacy + + @property + def read_only(self) -> bool: + return self.intent == INTENT_REVIEW or self.fail_closed or self.legacy + + @property + def promotable(self) -> bool: + return self.promotion_allowed + + @property + def digest(self) -> str: + return workflow_intent_digest(self) + + def to_dict(self) -> dict[str, Any]: + payload = { + "schema_version": "figops-workflow-intent/1", + "intent": self.intent, + "source": self.source, + "provenance": dict(self.provenance), + } + payload.update( + fail_closed=self.fail_closed, legacy=self.legacy, execution_allowed=self.execution_allowed, + promotion_allowed=self.promotion_allowed, read_only=self.read_only, promotable=self.promotable, + issues=list(self.issues), + ) + return payload + + +def workflow_intent_digest(intent: WorkflowIntent) -> str: + payload = json.dumps(intent.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _fail_closed( + *, + intent: str | None, + source: str | None, + provenance: Mapping[str, Any], + issues: tuple[str, ...], +) -> WorkflowIntent: + return WorkflowIntent(intent=intent, source=source, provenance=provenance, fail_closed=True, issues=issues) + + +def _surface_intent( + surface_source: str, + provenance: Mapping[str, Any], + requested_intent: str | None, + requested_source: str | None, + default_intent: str | None = None, + compatible_sources: frozenset[str] | None = None, +) -> WorkflowIntent: + rule_default, compatible_intents, rule_sources = SURFACE_RULES[surface_source] + default_intent = default_intent or rule_default + compatible_sources = compatible_sources or rule_sources + if requested_source is not None and requested_source not in compatible_sources: + safe_intent = INTENT_REVIEW if requested_source in {SOURCE_READ_ONLY, SOURCE_READINESS} else default_intent + return _fail_closed( + intent=safe_intent, + source=surface_source, + provenance=provenance, + issues=(f"requested source {requested_source!r} conflicts with {surface_source!r} surface",), + ) + if requested_intent is not None and requested_intent not in compatible_intents: + safe_intent = default_intent if requested_intent == INTENT_EXECUTION else requested_intent + return _fail_closed( + intent=safe_intent, + source=surface_source, + provenance=provenance, + issues=( + f"requested intent {requested_intent!r} conflicts with " + f"{surface_source!r} surface default {default_intent!r}", + ), + ) + return WorkflowIntent(intent=default_intent, source=surface_source, provenance=provenance) + + +def infer_workflow_intent( + *, + active: bool = False, + step: object = None, + tool_name: object = None, + source: object = None, + requested_intent: object = None, + project_status: object = None, +) -> WorkflowIntent: + provenance: dict[str, Any] = { + "active": bool(active), + "step": None if step is None else str(step), + "tool_name": None if tool_name is None else _tool_name(tool_name), + "requested_intent": None if requested_intent is None else str(requested_intent), + "requested_source": None if source is None else str(source), + "project_status": None if project_status is None else str(project_status), + } + issues: list[str] = [] + parsed_intent: str | None = None + parsed_source: str | None = None + if requested_intent is not None: + try: + parsed_intent = parse_workflow_intent(requested_intent) + except WorkflowIntentError as exc: + issues.append(str(exc)) + if source is not None: + try: + parsed_source = parse_workflow_source(source) + except WorkflowIntentError as exc: + issues.append(str(exc)) + + tool = provenance["tool_name"] or "" + if _legacy_surface(tool, project_status, parsed_source): + return WorkflowIntent( + intent=INTENT_REVIEW, + source=SOURCE_LEGACY, + provenance=provenance, + legacy=True, + issues=("legacy workflows are read-only, non-promotable, and never infer execution",), + ) + if issues: + return _fail_closed(intent=parsed_intent, source=parsed_source, provenance=provenance, issues=tuple(issues)) + + if step is not None: + try: + normalized_step = _normalize_token(step, field_name="orchestrator step") + except WorkflowIntentError as exc: + return _fail_closed( + intent=INTENT_REVIEW, + source=SOURCE_ORCHESTRATOR, + provenance=provenance, + issues=(str(exc),), + ) + if active and normalized_step in ORCHESTRATOR_EXECUTION_STEPS: + return _surface_intent(SOURCE_ORCHESTRATOR, provenance, parsed_intent, parsed_source) + issue = f"unknown or inactive orchestrator execution step: {step!r}" + return _fail_closed(intent=INTENT_REVIEW, source=SOURCE_ORCHESTRATOR, provenance=provenance, issues=(issue,)) + if tool: + if active and tool in MCP_EXECUTION_TOOLS: + return _surface_intent(SOURCE_MCP, provenance, parsed_intent, parsed_source) + if active and tool in DIRECT_CSV_TOOLS: + return _surface_intent(SOURCE_DIRECT_CSV, provenance, parsed_intent, parsed_source) + if tool in READINESS_TOOLS: + return _surface_intent(SOURCE_READINESS, provenance, parsed_intent, parsed_source) + if tool in READ_ONLY_TOOLS or not active: + return _surface_intent(SOURCE_READ_ONLY, provenance, parsed_intent, parsed_source) + return _fail_closed( + intent=INTENT_REVIEW, + source=parsed_source, + provenance=provenance, + issues=(f"unknown active workflow tool: {tool!r}",), + ) + if parsed_source in {SOURCE_READ_ONLY, SOURCE_READINESS}: + return _surface_intent( + parsed_source, + provenance, + parsed_intent, + parsed_source, + compatible_sources=frozenset({parsed_source}), + ) + if parsed_source == SOURCE_DIRECT_CSV: + return _surface_intent( + parsed_source, + provenance, + parsed_intent, + parsed_source, + default_intent=INTENT_EXPLORATION if active else INTENT_REVIEW, + compatible_sources=frozenset({parsed_source}), + ) + if parsed_intent is not None and parsed_source in {None, SOURCE_EXPLICIT}: + return WorkflowIntent(intent=parsed_intent, source=parsed_source or SOURCE_EXPLICIT, provenance=provenance) + return WorkflowIntent(intent=INTENT_REVIEW, source=parsed_source or SOURCE_READ_ONLY, provenance=provenance) diff --git a/orchestrator.py b/orchestrator.py index 7ea6dbb..be88d7b 100644 --- a/orchestrator.py +++ b/orchestrator.py @@ -119,6 +119,8 @@ def _apply_cli_preset(config: dict, preset_name: str) -> None: def _selector_kind(args: argparse.Namespace) -> str: + if getattr(args, "audit_structure", False): + return "audit_structure" if args.check_all: return "check_all" if args.project: @@ -237,6 +239,20 @@ def main(): parser.add_argument( "--check-all", action="store_true", help="Run all discoverable projects and write a regression report" ) + parser.add_argument( + "--audit-structure", + action="store_true", + help=( + "Inspect the declared structure of all discoverable projects without executing pipelines " + "or modifying project files" + ), + ) + parser.add_argument( + "--audit-structure-format", + choices=["json", "markdown"], + default=None, + help="Output format for --audit-structure (default: markdown)", + ) parser.add_argument( "--reformat-journal", type=str, @@ -330,6 +346,7 @@ def main(): "--check-regression": args.check_regression, "--docker": args.docker or args.docker_build, "--reformat-journal": bool(args.reformat_journal), + "--audit-structure": args.audit_structure, } conflicts = [name for name, active in conflicting_modes.items() if active] if conflicts: @@ -355,6 +372,71 @@ def main(): sys.stdout.write("Error: --readiness-format requires --readiness-manifest.\n") return 1 + if args.audit_structure_format and not args.audit_structure: + sys.stdout.write("Error: --audit-structure-format requires --audit-structure.\n") + return 1 + + if args.audit_structure: + conflicting_modes = { + "--project": bool(args.project), + "--list-projects/--status": args.list_projects or args.status, + "--list-root-only": args.list_root_only, + "--init/--wizard": args.init or args.wizard, + "--check-all": args.check_all, + "--read-fingerprint": bool(args.read_fingerprint), + "--inject-fingerprint": args.inject_fingerprint, + "--sweep": args.sweep, + "--comparison": args.comparison, + "--freeze-golden": args.freeze_golden, + "--check-regression": args.check_regression, + "--docker": args.docker or args.docker_build, + "--reformat-journal": bool(args.reformat_journal), + "--readiness-manifest": bool(args.readiness_manifest), + "--readiness-format": bool(args.readiness_format), + "--external-raw-root": bool(args.external_raw_root), + "--preset": bool(args.preset), + "--force": args.force, + "--strict-lock": args.strict_lock, + "--step": args.step != "all", + "--regression-baseline": args.regression_baseline != "ignore", + } + conflicts = [name for name, active in conflicting_modes.items() if active] + if conflicts: + sys.stdout.write( + "Error: --audit-structure is an independent read-only mode and cannot be combined with " + + ", ".join(conflicts) + + ".\n" + ) + return 1 + + root_dir = inferred_root_dir + hub_path = inferred_hub_path + attempt_provenance = build_attempt_provenance( + surface="cli", + step="audit_structure", + selector_kind=_selector_kind(args), + hub_path=hub_path, + ) + _emit_attempt_provenance(attempt_provenance) + try: + from hub_core import structure_audit_report as structure_audit_api + + build_structure_audit_report = structure_audit_api.build_structure_audit_report + render_structure_audit_report = getattr(structure_audit_api, "render_structure_audit_report", None) + report = build_structure_audit_report(root_dir, max_depth=args.scan_depth) + output_format = args.audit_structure_format or "markdown" + if render_structure_audit_report is not None: + output = render_structure_audit_report(report, output_format=output_format) + elif output_format == "json": + output = structure_audit_api.render_structure_audit_json(report) + else: + output = structure_audit_api.render_structure_audit_markdown(report) + except (AttributeError, ImportError, OSError, RuntimeError, TypeError, ValueError) as exc: + sys.stdout.write(f"Error: unable to audit project structure: {exc}\n") + return 1 + sys.stdout.write(output) + return 0 + root_dir = inferred_root_dir hub_path = inferred_hub_path attempt_provenance = build_attempt_provenance( diff --git a/project_config_template.yaml b/project_config_template.yaml index 5355a10..f45c711 100644 --- a/project_config_template.yaml +++ b/project_config_template.yaml @@ -37,6 +37,10 @@ project: version: "1.0.0" target_journal: "Nature Communications" +# 1.1 Workflow intent +workflow: + intent: "execution" # exploration | execution | review | promotion + # 2. 통합 시각화 스타일 설정 (Theme Vending Machine) visual_style: render_policy: "neutral" # preserves authored styling; independent of validation diff --git a/pyproject.toml b/pyproject.toml index 19e8a4c..a352a25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "figops" -version = "0.20.0" +version = "0.20.1" description = "Reproducible plotting and analysis pipeline for research projects" readme = "README.md" requires-python = ">=3.12" @@ -27,7 +27,7 @@ dependencies = [ "openpyxl>=3.1,<4", "rich>=13.0,<15", "markdown>=3.10.2", - "pymdown-extensions>=10.21.3", + "pymdown-extensions>=11.0.0", "python-pptx>=1.0.2", "svgutils>=0.3.4", "lxml>=6.1.0", diff --git a/tests/fixtures/ai_native_agent_eval/final-v2.json b/tests/fixtures/ai_native_agent_eval/final-v2.json index 33a7bfe..55cf049 100644 --- a/tests/fixtures/ai_native_agent_eval/final-v2.json +++ b/tests/fixtures/ai_native_agent_eval/final-v2.json @@ -19,8 +19,8 @@ "figops.audit_artifact" ], "emitted_tool_definition_count": 7, - "tools_list_response_bytes": 11973, - "tools_array_bytes": 11929, + "tools_list_response_bytes": 14477, + "tools_array_bytes": 14433, "maximum_input_schema_bytes": 1102, "maximum_input_schema_tool": "figops.render_basic_csv", "render_basic_csv_top_level_properties": 12 @@ -45,18 +45,18 @@ "generated_references": { "default_full": { "path": "docs/tools.md", - "bytes": 157174, - "lines": 7707 + "bytes": 167680, + "lines": 8161 }, "v2": { "path": "docs/tools-v2.md", - "bytes": 18389, - "lines": 1071 + "bytes": 23642, + "lines": 1298 }, "compatibility": { "path": "docs/tools-compatibility.md", - "bytes": 131686, - "lines": 5992 + "bytes": 136939, + "lines": 6219 } }, "guided_call_contract": { diff --git a/tests/human_review_receipt_helpers.py b/tests/human_review_receipt_helpers.py new file mode 100644 index 0000000..a165e4e --- /dev/null +++ b/tests/human_review_receipt_helpers.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from typing import Any + +from hub_core.human_review_receipt import ( + HumanReviewAuthorityBinding, + HumanReviewVerificationPolicy, + HumanReviewWaiverBinding, + build_human_review_receipt, + build_review_subject, + build_reviewer, + opaque_concern_id, + opaque_figure_artifact_id, + opaque_principal_id, + opaque_project_id, + opaque_waiver_id, +) + +DEFAULT_SCOPE = "figure_scientific_and_communication" +DEFAULT_AUTHORITY = "lab-policy/1" + + +def subject(decision_scope: str = DEFAULT_SCOPE) -> dict[str, str]: + return build_review_subject( + project_id=opaque_project_id("project-17"), + artifact_id=opaque_figure_artifact_id("figure-1.svg"), + artifact_sha256="1" * 64, + lineage_receipt_sha256="2" * 64, + evidence_digest="3" * 64, + resolved_policy_digest="4" * 64, + decision_scope=decision_scope, + ) + + +def reviewer( + principal_source: str = "reviewer@example.invalid", + *, + role: str = "scientific_reviewer", +) -> dict[str, str]: + return build_reviewer( + principal_id=opaque_principal_id(principal_source), + role=role, + authority_assertion=DEFAULT_AUTHORITY, + ) + + +def policy( + *, + allow_local_attestation: bool = True, + role: str = "scientific_reviewer", + authority: str = DEFAULT_AUTHORITY, + waiver_bindings: frozenset[HumanReviewWaiverBinding] = frozenset(), +) -> HumanReviewVerificationPolicy: + return HumanReviewVerificationPolicy( + allow_local_attestation=allow_local_attestation, + reviewer_bindings=frozenset( + { + HumanReviewAuthorityBinding( + decision_scope=DEFAULT_SCOPE, + reviewer_role=role, + authority_assertion=authority, + ) + } + ), + waiver_bindings=waiver_bindings, + ) + + +def receipt(**overrides: object) -> dict[str, Any]: + decision_scope = str(overrides.pop("decision_scope", DEFAULT_SCOPE)) + payload: dict[str, Any] = { + "decision": "approve_for_promotion", + "decision_scope": decision_scope, + "subject": overrides.pop("subject", subject(decision_scope)), + "reviewer": reviewer(), + "reviewed_at": "2026-07-20T00:00:00Z", + "expires_at": "2026-10-18T00:00:00Z", + "concerns": [], + "waivers": [], + "supersedes": None, + } + payload.update(overrides) + return build_human_review_receipt(**payload) + + +def advisory_waiver_fixture() -> tuple[dict[str, str], dict[str, str], HumanReviewWaiverBinding]: + review_subject = subject() + concern_id = opaque_concern_id("color-waiver") + concern = { + "concern_id": concern_id, + "category": "accessibility", + "severity": "advisory", + "status": "waived", + "subject_digest": review_subject["subject_digest"], + "finding_digest": "5" * 64, + } + waiver = { + "waiver_id": opaque_waiver_id("color-waiver-approval"), + "concern_id": concern_id, + "policy_rule": DEFAULT_AUTHORITY, + "rationale_digest": "6" * 64, + "authorized_principal_id": opaque_principal_id("pi@example.invalid"), + "authorized_role": "principal_investigator", + "subject_digest": review_subject["subject_digest"], + "expires_at": "2026-09-01T00:00:00Z", + } + binding = HumanReviewWaiverBinding( + policy_rule=DEFAULT_AUTHORITY, + authorized_role="principal_investigator", + concern_category="accessibility", + concern_severity="advisory", + ) + return concern, waiver, binding diff --git a/tests/test_approval_authority.py b/tests/test_approval_authority.py new file mode 100644 index 0000000..2d30d5d --- /dev/null +++ b/tests/test_approval_authority.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import copy +from copy import deepcopy + +from hub_core.approval_authority import ( + ApprovalAuthorityRoot, + ApprovalRecord, + canonical_plan_digest, + verify_approval_authority, +) + + +def _plan() -> dict: + plan = { + "version": "2", + "project_root_identity": {"device": 1, "inode": 42}, + "config_sha256": "a" * 64, + "config_identity": {"device": 1, "inode": 99}, + "entries": [{"source": "legacy/a.csv", "destination": "raw/a.csv", "role": "raw"}], + "approved_mappings": [{"source": "legacy/a.csv", "destination": "raw/a.csv", "role": "raw"}], + "config_diff": [], + "hardcoded_unresolved_references": [], + "unresolved_proposals": [], + } + plan["digest"] = canonical_plan_digest(plan) + return plan + + +def _issue(root: ApprovalAuthorityRoot, plan: dict, **kwargs): + options = { + "reviewer_identity": "principal-investigator:alice", + "reviewer_role": "principal_investigator", + "issued_at": "2026-07-24T00:00:00Z", + "expires_at": "2026-07-25T00:00:00Z", + } + options.update(kwargs) + return root.issue(plan, **options) + + +def test_approval_is_only_valid_through_the_host_owned_root() -> None: + plan = _plan() + root = ApprovalAuthorityRoot() + record = _issue(root, plan) + + assert verify_approval_authority(plan, record.receipt_id, root, now="2026-07-24T12:00:00Z").valid + assert not verify_approval_authority(plan, record.receipt_id, {"records": {}}, now="2026-07-24T12:00:00Z") + assert not verify_approval_authority(plan, record.receipt_id, None, now="2026-07-24T12:00:00Z") + try: + copy.copy(root) + except TypeError: + pass + else: # pragma: no cover - defensive assertion + raise AssertionError("authority roots must not be copied") + + +def test_self_generated_mapping_or_record_cannot_be_authority() -> None: + plan = _plan() + root = ApprovalAuthorityRoot() + record = _issue(root, plan) + copied = deepcopy(record.binding.canonical_payload()) + + assert not verify_approval_authority(plan, copied, root) # type: ignore[arg-type] + try: + ApprovalRecord() # type: ignore[call-arg] + except TypeError: + pass + else: # pragma: no cover - defensive assertion + raise AssertionError("ApprovalRecord must only be minted by the root") + + +def test_stale_or_mutated_plan_is_rejected() -> None: + plan = _plan() + root = ApprovalAuthorityRoot() + record = _issue(root, plan) + + mutated = deepcopy(plan) + mutated["entries"][0]["destination"] = "raw/changed.csv" + # The old self-referential digest is intentionally retained. + assert not verify_approval_authority(mutated, record.receipt_id, root, now="2026-07-24T12:00:00Z") + + stale = deepcopy(plan) + stale["digest"] = "0" * 64 + assert not verify_approval_authority(stale, record.receipt_id, root, now="2026-07-24T12:00:00Z") + + omitted = deepcopy(plan) + omitted.pop("unresolved_proposals") + omitted["digest"] = canonical_plan_digest(omitted) + assert not verify_approval_authority(omitted, record.receipt_id, root, now="2026-07-24T12:00:00Z") + + +def test_expiry_and_revoke_are_fail_closed() -> None: + plan = _plan() + root = ApprovalAuthorityRoot() + record = _issue(root, plan) + + assert not verify_approval_authority(plan, record.receipt_id, root, now="2026-07-25T00:00:00Z") + root.revoke(record.receipt_id) + assert not verify_approval_authority(plan, record.receipt_id, root, now="2026-07-24T12:00:00Z") + + +def test_supersede_marks_prior_record_non_current() -> None: + plan = _plan() + root = ApprovalAuthorityRoot() + old = _issue(root, plan) + replacement = _issue(root, plan, supersedes=old.receipt_id, issued_at="2026-07-24T01:00:00Z") + + assert not verify_approval_authority(plan, old.receipt_id, root, now="2026-07-24T12:00:00Z") + assert verify_approval_authority(plan, replacement.receipt_id, root, now="2026-07-24T12:00:00Z").valid diff --git a/tests/test_config_workflow_intent.py b/tests/test_config_workflow_intent.py new file mode 100644 index 0000000..fd6868d --- /dev/null +++ b/tests/test_config_workflow_intent.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import copy +from pathlib import Path + +import yaml + +from hub_core.config_parser import ( + normalize_workflow_defaults, + validate_config, + workflow_intent, + workflow_intent_report, +) +from hub_core.project_layout import build_scaffold_config_text +from hub_core.workflow_intent import INTENT_EXECUTION, INTENT_EXPLORATION, INTENT_REVIEW, infer_workflow_intent + + +def _minimal_config() -> dict: + return { + "project": {"name": "Workflow Intent Demo"}, + "visual_style": {"target_format": "neutral"}, + } + + +def test_active_project_without_declared_workflow_normalizes_to_execution() -> None: + config = normalize_workflow_defaults(copy.deepcopy(_minimal_config())) + + assert validate_config(config) == [] + assert config["workflow"]["intent"] == INTENT_EXECUTION + assert workflow_intent(config) == INTENT_EXECUTION + report = workflow_intent_report(config) + assert report["execution_allowed"] is True + assert report["provenance"]["config_source"] == "declared" + + +def test_unknown_workflow_intent_fails_closed_but_remains_inspectable() -> None: + config = _minimal_config() + config["workflow"] = {"intent": "run"} + + errors = validate_config(config) + report = workflow_intent_report(config) + + assert any("Invalid workflow.intent" in error for error in errors) + assert report["intent"] is None + assert report["execution_allowed"] is False + assert report["fail_closed"] is True + assert report["provenance"]["config_source"] == "declared-invalid" + assert report["issues"] == ["unknown workflow intent: 'run'"] + + +def test_legacy_projects_normalize_to_read_only_non_promotable_intent() -> None: + config = _minimal_config() + config["project"]["status"] = "legacy" + normalized = normalize_workflow_defaults(copy.deepcopy(config)) + explicit_execution = copy.deepcopy(config) + explicit_execution["workflow"] = {"intent": INTENT_EXECUTION} + + assert normalized["workflow"]["intent"] == INTENT_REVIEW + report = workflow_intent_report(normalized) + assert report["intent"] == INTENT_REVIEW + assert report["execution_allowed"] is False + assert report["promotable"] is False + assert report["read_only"] is True + assert any("legacy" in error for error in validate_config(explicit_execution)) + + +def test_explicit_project_draft_intent_conflicts_with_execution_surface_without_mutating_config() -> None: + config = normalize_workflow_defaults(copy.deepcopy(_minimal_config())) + config["workflow"]["intent"] = INTENT_EXPLORATION + + report = workflow_intent_report(config, active=True, step="plot") + + assert config["workflow"]["intent"] == INTENT_EXPLORATION + assert validate_config(config) == [] + assert report["intent"] == INTENT_EXPLORATION + assert report["source"] == "orchestrator" + assert report["execution_allowed"] is False + assert report["fail_closed"] is True + assert report["provenance"]["config_source"] == "declared" + assert "conflicts with 'orchestrator' surface" in report["issues"][0] + + +def test_direct_csv_and_read_only_compatibility_intents_never_enable_execution() -> None: + csv_intent = infer_workflow_intent(active=True, tool_name="figops.render_csv_graph") + read_only_intent = infer_workflow_intent(active=True, tool_name="figops.inspect_project") + + assert csv_intent.intent == INTENT_EXPLORATION + assert csv_intent.execution_allowed is False + assert csv_intent.promotable is False + assert read_only_intent.intent == INTENT_REVIEW + assert read_only_intent.execution_allowed is False + assert read_only_intent.read_only is True + + +def test_project_templates_and_scaffolded_config_declare_execution_intent() -> None: + hub_path = Path(__file__).resolve().parents[1] + template_paths = [ + hub_path / "project_config_template.yaml", + hub_path / "hub_core" / "templates" / "project_config_template.yaml", + ] + + for template_path in template_paths: + config = yaml.safe_load(template_path.read_text(encoding="utf-8")) + assert config["workflow"]["intent"] == INTENT_EXECUTION + assert validate_config(config) == [] + + scaffolded = yaml.safe_load( + build_scaffold_config_text(hub_path, "Scaffold Workflow Demo", "neutral", font_scale=1.0) + ) + assert scaffolded["workflow"]["intent"] == INTENT_EXECUTION + assert validate_config(scaffolded) == [] diff --git a/tests/test_dependency_script_inspection.py b/tests/test_dependency_script_inspection.py new file mode 100644 index 0000000..41378ff --- /dev/null +++ b/tests/test_dependency_script_inspection.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from hub_core.dependency_script_inspection import analyze_dependency_script + + +def test_python_imports_and_path_literals_are_explicit_candidates() -> None: + result = analyze_dependency_script( + "import pandas as pd\n" + "from pathlib import Path\n" + "frame = pd.read_csv(\"data/input.csv\")\n", + ".py", + ) + + assert result["inspectable"] is True + assert result["dependency_scan_incomplete"] is False + candidates = result["static_candidates"] + assert any(item["kind"] == "import" and item["path"] == "pandas" for item in candidates) + assert any(item["kind"] == "import" and item["path"] == "pathlib" for item in candidates) + assert [item["path"] for item in candidates if item["kind"] == "path_literal"] == ["data/input.csv"] + assert result["hardcoded_unresolved_references"] + + +def test_r_source_and_read_calls_are_explicit_candidates() -> None: + result = analyze_dependency_script( + 'source("scripts/helper.R")\n' + 'values <- read.csv("data/input.csv")\n', + ".r", + ) + + assert result["inspectable"] is True + assert result["dependency_scan_incomplete"] is False + assert [item["path"] for item in result["static_candidates"]] == [ + "scripts/helper.R", + "data/input.csv", + ] + assert len(result["hardcoded_unresolved_references"]) == 2 + + +def test_suffixless_python_path_api_literals_are_candidates() -> None: + result = analyze_dependency_script( + "from pathlib import Path\n" + "frame = read_csv('input')\n" + "path = Path('workspace')\n" + "handle = open('README')\n", + ".py", + ) + + assert result["dependency_scan_incomplete"] is False + assert {item["path"] for item in result["static_candidates"] if item["kind"] == "path_literal"} == { + "input", + "workspace", + "README", + } + + wrapped = analyze_dependency_script("handle = open(Path('wrapped'))", ".py") + assert wrapped["dependency_scan_incomplete"] is False + assert [item["path"] for item in wrapped["static_candidates"] if item["kind"] == "path_literal"] == [ + "wrapped" + ] + + content = analyze_dependency_script("Path('output').write_text(content_value)", ".py") + assert content["dependency_scan_incomplete"] is False + assert [item["path"] for item in content["static_candidates"] if item["kind"] == "path_literal"] == [ + "output" + ] + + assigned = analyze_dependency_script("input_path = Path('assigned')\nread_csv(input_path)", ".py") + assert assigned["dependency_scan_incomplete"] is False + assert [item["path"] for item in assigned["static_candidates"] if item["kind"] == "path_literal"] == [ + "assigned" + ] + + +def test_suffixless_r_read_and_source_literals_are_candidates() -> None: + result = analyze_dependency_script( + 'source("helper")\n' + 'values <- read.csv("input")\n', + ".r", + ) + + assert result["inspectable"] is True + assert result["dependency_scan_incomplete"] is False + assert [item["path"] for item in result["static_candidates"]] == ["helper", "input"] + assert len(result["hardcoded_unresolved_references"]) == 2 + + +def test_python_path_keyword_variants_remain_unresolved_when_dynamic() -> None: + result = analyze_dependency_script( + "frame = read_csv(filepath_or_buffer=input_path)\n", + ".py", + ) + + assert result["dependency_scan_incomplete"] is True + assert result["static_candidates"] == [] + assert result["hardcoded_unresolved_references"][0]["kind"] == "dynamic_path" + + +def test_python_syntax_failure_fails_closed() -> None: + result = analyze_dependency_script("if True print('broken')", ".py") + + assert result["inspectable"] is False + assert result["dependency_scan_incomplete"] is True + assert result["static_candidates"] == [] + assert result["hardcoded_unresolved_references"][0]["kind"] == "parse_error" + + +def test_r_unbalanced_call_fails_closed() -> None: + result = analyze_dependency_script('values <- read.csv("data/input.csv"', ".r") + + assert result["inspectable"] is False + assert result["dependency_scan_incomplete"] is True + assert result["static_candidates"] == [] + assert result["hardcoded_unresolved_references"][0]["kind"] == "parse_error" + + +def test_dynamic_path_is_incomplete_and_unresolved() -> None: + result = analyze_dependency_script("frame = read_csv(input_path)", ".py") + + assert result["inspectable"] is True + assert result["dependency_scan_incomplete"] is True + assert result["static_candidates"] == [] + assert result["hardcoded_unresolved_references"][0]["kind"] == "dynamic_path" + + +def test_unmapped_path_is_not_assigned_a_guessed_role() -> None: + result = analyze_dependency_script('frame = read_csv("data/input.csv")', ".py") + + unresolved = result["hardcoded_unresolved_references"] + assert len(unresolved) == 1 + assert unresolved[0]["kind"] == "hardcoded_path" + assert "role mapping" in unresolved[0]["reason"] + + +def test_explicit_role_root_can_resolve_a_literal_without_guessing() -> None: + result = analyze_dependency_script( + 'frame = read_csv("raw/input.csv")', + ".py", + role_roots={"raw": "raw"}, + ) + + assert result["static_candidates"][0]["path"] == "raw/input.csv" + assert result["hardcoded_unresolved_references"] == [] + + +def test_nested_role_roots_resolve_only_the_terminal_declared_role() -> None: + result = analyze_dependency_script( + 'frame = read_csv("hub_scripts/analysis/input")', + ".py", + role_roots={ + "scripts": "hub_scripts", + "analysis_scripts": "hub_scripts/analysis", + }, + ) + + assert result["hardcoded_unresolved_references"] == [] + + +def test_aggregate_role_root_does_not_clear_a_dependency() -> None: + result = analyze_dependency_script( + 'frame = read_csv("hub_scripts/input")', + ".py", + role_roots={"scripts": "hub_scripts"}, + ) + + assert result["hardcoded_unresolved_references"][0]["kind"] == "hardcoded_path" + + +@pytest.mark.parametrize( + ("suffix", "source"), + [ + (".py", "frame = read_csv('https://example.test/input.csv')"), + (".r", 'frame <- read.csv("https://example.test/input.csv")'), + ], +) +def test_remote_path_literals_remain_fail_closed(suffix: str, source: str) -> None: + result = analyze_dependency_script(source, suffix) + + assert result["dependency_scan_incomplete"] is True + assert result["static_candidates"] == [] + assert result["hardcoded_unresolved_references"][0]["kind"] == "external_path" + + +def test_result_is_deterministic() -> None: + source = 'b = read_csv("b.csv")\na = read_csv("a.csv")\n' + + assert analyze_dependency_script(source, ".py") == analyze_dependency_script(source, ".py") + + +def test_unresolved_scanner_output_can_be_handed_to_apply_gate(tmp_path: Path) -> None: + from hub_core.structure_apply import apply_structure_plan + from hub_core.structure_plan import build_structure_plan, confirmation_token + + source = tmp_path / "legacy" / "input.csv" + source.parent.mkdir() + source.write_text("x\n1\n", encoding="utf-8") + evidence = analyze_dependency_script('frame = read_csv("legacy/input.csv")', ".py") + plan = build_structure_plan( + tmp_path, + [{"source": "legacy/input.csv", "destination": "raw/input.csv", "role": "raw"}], + hardcoded_unresolved_references=evidence["hardcoded_unresolved_references"], + ) + + with pytest.raises(RuntimeError, match="hard-coded"): + apply_structure_plan(plan, confirmation_token=confirmation_token(plan)) diff --git a/tests/test_evidence_contract.py b/tests/test_evidence_contract.py index 9093db5..71cba28 100644 --- a/tests/test_evidence_contract.py +++ b/tests/test_evidence_contract.py @@ -1388,6 +1388,84 @@ def test_resolved_policy_with_identity_version_and_source_is_valid() -> None: validate_evidence_envelope(envelope) +def test_policy_context_with_digest_and_render_policy_is_valid() -> None: + envelope = _minimal_envelope() + envelope["policy_context"] = { + "schema_version": "figops-render-policy-context/1", + "source": "v2-default", + "policy_set_sha256": "b" * 64, + "render_policy": { + "id": "render-neutral", + "version": "1", + "source": "v2-default", + "parameters": {"style_policy": "neutral"}, + }, + "validation_target": None, + } + + normalized = normalize_evidence_envelope(envelope) + + assert normalized["policy_context"]["policy_set_sha256"] == "b" * 64 + + +@pytest.mark.parametrize( + ("field", "value", "code"), + [ + ("policy_set_sha256", None, "SHA256_INVALID"), + ("policy_set_sha256", "not-a-digest", "SHA256_INVALID"), + ("schema_version", "figops-render-policy-context/0", "POLICY_CONTEXT_SCHEMA_INVALID"), + ("source", "", "NONEMPTY_STRING"), + ("render_policy", None, "TYPE_MAPPING"), + ("validation_target", "", "NONEMPTY_STRING"), + ], +) +def test_policy_context_rejects_malformed_fields(field: str, value: object, code: str) -> None: + envelope = _minimal_envelope() + envelope["policy_context"] = { + "schema_version": "figops-render-policy-context/1", + "source": "v2-default", + "policy_set_sha256": "b" * 64, + "render_policy": { + "id": "render-neutral", + "version": "1", + "source": "v2-default", + "parameters": {}, + }, + "validation_target": None, + } + if value is None: + envelope["policy_context"].pop(field) + else: + envelope["policy_context"][field] = value + + with pytest.raises(EvidenceContractError) as raised: + validate_evidence_envelope(envelope) + + assert raised.value.code == code + + +def test_policy_context_rejects_unknown_fields() -> None: + envelope = _minimal_envelope() + envelope["policy_context"] = { + "schema_version": "figops-render-policy-context/1", + "source": "v2-default", + "policy_set_sha256": "b" * 64, + "render_policy": { + "id": "render-neutral", + "version": "1", + "source": "v2-default", + "parameters": {}, + }, + "validation_target": None, + "policy_set": {}, + } + + with pytest.raises(EvidenceContractError) as raised: + validate_evidence_envelope(envelope) + + assert raised.value.code == "UNKNOWN_FIELD" + + @pytest.mark.parametrize("field", ["id", "version", "source"]) def test_resolved_policy_requires_identity_version_and_source(field: str) -> None: envelope = _minimal_envelope() diff --git a/tests/test_human_review_receipt.py b/tests/test_human_review_receipt.py new file mode 100644 index 0000000..81a9f30 --- /dev/null +++ b/tests/test_human_review_receipt.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import json +import unicodedata +from datetime import UTC, datetime + +import pytest + +from hub_core.human_review_receipt import ( + HumanReviewReceiptError, + build_review_subject, + build_reviewer, + canonical_human_review_receipt_bytes, + canonical_review_payload_bytes, + human_review_receipt_digest, + opaque_figure_artifact_id, + opaque_principal_id, + opaque_project_id, + parse_human_review_receipt_bytes, + validate_human_review_receipt, + verify_human_review_receipt, +) +from tests.human_review_receipt_helpers import policy, receipt, reviewer, subject + + +def test_happy_path_constructs_canonical_bytes_digest_and_verifies() -> None: + review = receipt() + + payload_bytes = canonical_review_payload_bytes(review) + full_bytes = canonical_human_review_receipt_bytes(review) + digest = human_review_receipt_digest(review) + verification = verify_human_review_receipt( + full_bytes, + policy=policy(), + now="2026-07-21T00:00:00Z", + expected_subject=review["subject"], + expected_subject_digest=review["subject"]["subject_digest"], + ) + + assert b"receipt_id" not in payload_bytes + assert b"integrity" not in payload_bytes + assert json.loads(full_bytes)["integrity"] == {"canonical_sha256": digest} + assert review["receipt_id"] == f"review:sha256:{digest}" + assert verification.valid + + +def test_parser_rejects_duplicate_json_keys() -> None: + review = receipt() + duplicate = ( + b'{"schema_version":"figops-human-review/1","schema_version":"figops-human-review/1",' + + canonical_human_review_receipt_bytes(review).lstrip(b"{") + ) + + with pytest.raises(HumanReviewReceiptError, match="duplicate JSON key"): + parse_human_review_receipt_bytes(duplicate) + + +def test_parser_rejects_bom_non_finite_json_and_non_bytes() -> None: + with pytest.raises(HumanReviewReceiptError, match="without a BOM"): + parse_human_review_receipt_bytes(b"\xef\xbb\xbf{}") + with pytest.raises(HumanReviewReceiptError, match="non-finite"): + parse_human_review_receipt_bytes(b'{"schema_version":NaN}') + with pytest.raises(HumanReviewReceiptError, match="input must be bytes"): + parse_human_review_receipt_bytes(None) # type: ignore[arg-type] + + +def test_unknown_top_level_and_nested_fields_fail_closed() -> None: + review = receipt() + review["unexpected"] = "value" + with pytest.raises(HumanReviewReceiptError, match="unsupported unexpected"): + validate_human_review_receipt(review) + + review = receipt() + review["reviewer"] = {**review["reviewer"], "display_name": "Dr. Reviewer"} + with pytest.raises(HumanReviewReceiptError, match="reviewer contains unsupported display_name"): + validate_human_review_receipt(review) + + +def test_tampering_with_payload_or_integrity_breaks_verification() -> None: + review = receipt() + review["decision"] = "decline" + result = verify_human_review_receipt(review, policy=policy(), now="2026-07-21T00:00:00Z") + assert not result.valid + assert "receipt_id does not match" in result.reason + + review = receipt() + review["integrity"] = {"canonical_sha256": "0" * 64} + with pytest.raises(HumanReviewReceiptError, match="integrity.canonical_sha256 does not match"): + validate_human_review_receipt(review) + + +def test_subject_mismatch_and_replay_are_rejected() -> None: + review = receipt() + replay_subject = build_review_subject( + project_id=opaque_project_id("different-project"), + artifact_id=review["subject"]["artifact_id"], + artifact_sha256=review["subject"]["artifact_sha256"], + lineage_receipt_sha256=review["subject"]["lineage_receipt_sha256"], + evidence_digest=review["subject"]["evidence_digest"], + resolved_policy_digest=review["subject"]["resolved_policy_digest"], + decision_scope=review["decision_scope"], + ) + + result = verify_human_review_receipt( + review, + policy=policy(), + now="2026-07-21T00:00:00Z", + expected_subject=replay_subject, + ) + + assert not result.valid + assert result.reason == "subject_mismatch" + + +def test_subject_digest_binds_decision_scope() -> None: + review_subject = subject("figure_visual_communication") + with pytest.raises(HumanReviewReceiptError, match="subject.subject_digest does not match"): + receipt(decision_scope="figure_scientific_and_communication", subject=review_subject) + + +def test_malformed_ids_hashes_and_path_like_subject_values_are_rejected() -> None: + with pytest.raises(HumanReviewReceiptError, match="subject.artifact_sha256"): + build_review_subject( + project_id=opaque_project_id("project-17"), + artifact_id=opaque_figure_artifact_id("figure-1"), + artifact_sha256="A" * 64, + lineage_receipt_sha256="2" * 64, + evidence_digest="3" * 64, + resolved_policy_digest="4" * 64, + decision_scope="figure_scientific_and_communication", + ) + + with pytest.raises(HumanReviewReceiptError, match="subject.project_id"): + build_review_subject( + project_id="C:/research/project", + artifact_id=opaque_figure_artifact_id("figure-1"), + artifact_sha256="1" * 64, + lineage_receipt_sha256="2" * 64, + evidence_digest="3" * 64, + resolved_policy_digest="4" * 64, + decision_scope="figure_scientific_and_communication", + ) + + +def test_timestamp_requires_utc_seconds_and_real_expiry_order() -> None: + with pytest.raises(HumanReviewReceiptError, match="reviewed_at"): + receipt(reviewed_at="2026-07-20T00:00:00+09:00") + with pytest.raises(HumanReviewReceiptError, match="reviewed_at"): + receipt(reviewed_at="2026-07-20T00:00:00.123Z") + with pytest.raises(HumanReviewReceiptError, match="expires_at must be later"): + receipt(expires_at="2026-07-20T00:00:00Z") + + +def test_nfc_string_normalization_is_used_for_canonical_payloads() -> None: + decomposed = "reviewer-e\u0301@example.invalid" + normalized_reviewer = reviewer(unicodedata.normalize("NFD", decomposed)) + review = receipt(reviewer=normalized_reviewer) + parsed = parse_human_review_receipt_bytes( + json.dumps(review, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + ) + + assert unicodedata.is_normalized("NFC", parsed["reviewer"]["principal_id"]) + assert parsed == review + + +def test_direct_malformed_mapping_inputs_raise_domain_errors_or_invalid_results() -> None: + with pytest.raises(HumanReviewReceiptError, match="must be a mapping"): + canonical_review_payload_bytes(None) # type: ignore[arg-type] + with pytest.raises(HumanReviewReceiptError, match="keys must be strings"): + validate_human_review_receipt({1: "bad"}) + result = verify_human_review_receipt(None, policy=policy(), now="2026-07-21T00:00:00Z") # type: ignore[arg-type] + assert not result.valid + assert "must be a mapping" in result.reason + + +def test_local_attestation_cannot_be_mislabeled_as_verified_identity() -> None: + with pytest.raises(HumanReviewReceiptError, match="reviewer.identity_kind"): + build_reviewer( + principal_id=opaque_principal_id("reviewer@example.invalid"), + role="scientific_reviewer", + authority_assertion="lab-policy/1", + identity_kind="federated_verified", + ) + + +def test_authority_assertion_requires_explicit_positive_numeric_version() -> None: + with pytest.raises(HumanReviewReceiptError, match="versioned policy binding token"): + build_reviewer( + principal_id=opaque_principal_id("reviewer@example.invalid"), + role="scientific_reviewer", + authority_assertion="lab-policy", + ) + + with pytest.raises(HumanReviewReceiptError, match="versioned policy binding token"): + build_reviewer( + principal_id=opaque_principal_id("reviewer@example.invalid"), + role="scientific_reviewer", + authority_assertion="lab-policy/0", + ) + + +def test_timezone_aware_datetime_now_is_supported() -> None: + result = verify_human_review_receipt(receipt(), policy=policy(), now=datetime(2026, 7, 21, tzinfo=UTC)) + assert result.valid diff --git a/tests/test_human_review_receipt_policy.py b/tests/test_human_review_receipt_policy.py new file mode 100644 index 0000000..3b13f58 --- /dev/null +++ b/tests/test_human_review_receipt_policy.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import pytest + +from hub_core.human_review_receipt import ( + HumanReviewAuthorityBinding, + HumanReviewReceiptError, + HumanReviewReceiptIndex, + HumanReviewVerificationPolicy, + HumanReviewWaiverBinding, + opaque_concern_id, + opaque_principal_id, + opaque_waiver_id, + verify_human_review_receipt, +) +from tests.human_review_receipt_helpers import DEFAULT_AUTHORITY, advisory_waiver_fixture, policy, receipt, subject + + +def test_local_attestation_allowed_is_not_sufficient_without_reviewer_binding() -> None: + review = receipt() + denied = verify_human_review_receipt( + review, + policy=HumanReviewVerificationPolicy(allow_local_attestation=True), + now="2026-07-21T00:00:00Z", + ) + + assert not denied.valid + assert denied.reason == "reviewer_not_authorized" + + +def test_reviewer_role_scope_and_authority_must_match_policy_binding() -> None: + review = receipt() + wrong_scope = HumanReviewVerificationPolicy( + allow_local_attestation=True, + reviewer_bindings=frozenset( + { + HumanReviewAuthorityBinding( + decision_scope="figure_visual_communication", + reviewer_role="scientific_reviewer", + authority_assertion=DEFAULT_AUTHORITY, + ) + } + ), + ) + wrong_role = policy(role="principal_investigator") + wrong_authority = policy(authority="publication-policy/1") + + assert verify_human_review_receipt(review, policy=wrong_scope, now="2026-07-21T00:00:00Z").reason == ( + "reviewer_not_authorized" + ) + assert verify_human_review_receipt(review, policy=wrong_role, now="2026-07-21T00:00:00Z").reason == ( + "reviewer_not_authorized" + ) + assert verify_human_review_receipt(review, policy=wrong_authority, now="2026-07-21T00:00:00Z").reason == ( + "reviewer_not_authorized" + ) + + +def test_local_attestation_permission_is_still_required_after_reviewer_binding() -> None: + review = receipt() + denied = verify_human_review_receipt( + review, + policy=policy(allow_local_attestation=False), + now="2026-07-21T00:00:00Z", + ) + allowed = verify_human_review_receipt(review, policy=policy(), now="2026-07-21T00:00:00Z") + + assert denied.reason == "local_attestation_not_allowed" + assert not denied.valid + assert allowed.valid + + +def test_expired_non_approval_and_index_states_fail_verification() -> None: + expired = verify_human_review_receipt( + receipt(expires_at="2026-07-21T00:00:00Z"), + policy=policy(), + now="2026-07-21T00:00:00Z", + ) + revision = verify_human_review_receipt( + receipt(decision="request_revision"), + policy=policy(), + now="2026-07-21T00:00:00Z", + ) + review = receipt() + revoked = verify_human_review_receipt( + review, + policy=policy(), + now="2026-07-21T00:00:00Z", + receipt_index=HumanReviewReceiptIndex(revoked_receipt_ids=frozenset({review["receipt_id"]})), + ) + + assert expired.reason == "expired" + assert revision.reason == "decision_not_approval" + assert revoked.reason == "revoked" + + +def test_advisory_waiver_requires_policy_authorized_binding() -> None: + concern, waiver, binding = advisory_waiver_fixture() + review = receipt(subject=subject(), concerns=[concern], waivers=[waiver]) + no_waiver_policy = policy() + wrong_rule_policy = policy( + waiver_bindings=frozenset( + { + HumanReviewWaiverBinding( + policy_rule="publication-policy/1", + authorized_role="principal_investigator", + concern_category="accessibility", + concern_severity="advisory", + ) + } + ) + ) + authorized_policy = policy(waiver_bindings=frozenset({binding})) + + assert verify_human_review_receipt(review, policy=no_waiver_policy, now="2026-08-01T00:00:00Z").reason == ( + "waiver_not_authorized" + ) + assert verify_human_review_receipt(review, policy=wrong_rule_policy, now="2026-08-01T00:00:00Z").reason == ( + "waiver_not_authorized" + ) + assert verify_human_review_receipt(review, policy=authorized_policy, now="2026-08-01T00:00:00Z").valid + assert verify_human_review_receipt(review, policy=authorized_policy, now="2026-09-01T00:00:00Z").reason == ( + "waiver_expired" + ) + + +def test_waiver_policy_rule_requires_explicit_positive_numeric_version() -> None: + concern, waiver, binding = advisory_waiver_fixture() + waiver["policy_rule"] = "lab-policy" + with pytest.raises(HumanReviewReceiptError, match="versioned policy binding token"): + receipt(subject=subject(), concerns=[concern], waivers=[waiver]) + + review = receipt() + malformed_policy = policy( + waiver_bindings=frozenset( + { + HumanReviewWaiverBinding( + policy_rule="lab-policy", + authorized_role=binding.authorized_role, + concern_category=binding.concern_category, + concern_severity=binding.concern_severity, + ) + } + ) + ) + result = verify_human_review_receipt(review, policy=malformed_policy, now="2026-07-21T00:00:00Z") + assert not result.valid + assert "verification policy waiver policy_rule" in result.reason + + +def test_required_scientific_concern_cannot_be_waived_at_receipt_validation() -> None: + review_subject = subject() + concern_id = opaque_concern_id("required-scientific") + concern = { + "concern_id": concern_id, + "category": "scientific", + "severity": "required", + "status": "waived", + "subject_digest": review_subject["subject_digest"], + "finding_digest": "5" * 64, + } + waiver = { + "waiver_id": opaque_waiver_id("blocked-required-scientific-waiver"), + "concern_id": concern_id, + "policy_rule": DEFAULT_AUTHORITY, + "rationale_digest": "6" * 64, + "authorized_principal_id": opaque_principal_id("pi@example.invalid"), + "authorized_role": "principal_investigator", + "subject_digest": review_subject["subject_digest"], + "expires_at": "2026-09-01T00:00:00Z", + } + + with pytest.raises(HumanReviewReceiptError, match="required scientific concerns cannot be waived"): + receipt(subject=review_subject, concerns=[concern], waivers=[waiver]) + + +def test_unresolved_approval_concern_is_rejected() -> None: + review_subject = subject() + concern = { + "concern_id": opaque_concern_id("axis-label-units"), + "category": "communication", + "severity": "required", + "status": "unresolved", + "subject_digest": review_subject["subject_digest"], + "finding_digest": "5" * 64, + } + + with pytest.raises(HumanReviewReceiptError, match="unresolved concerns"): + receipt(subject=review_subject, concerns=[concern]) + + +def test_malformed_policy_and_index_return_invalid_verification_results() -> None: + review = receipt() + bad_policy = verify_human_review_receipt( + review, + policy={"allow_local_attestation": True}, # type: ignore[arg-type] + now="2026-07-21T00:00:00Z", + ) + bad_index = verify_human_review_receipt( + review, + policy=policy(), + now="2026-07-21T00:00:00Z", + receipt_index={"revoked_receipt_ids": []}, # type: ignore[arg-type] + ) + + assert not bad_policy.valid + assert "verification policy" in bad_policy.reason + assert not bad_index.valid + assert "receipt_index" in bad_index.reason diff --git a/tests/test_mcp_host_approval.py b/tests/test_mcp_host_approval.py new file mode 100644 index 0000000..0ffac42 --- /dev/null +++ b/tests/test_mcp_host_approval.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from pathlib import Path + +import hub_core.mcp.tools.project_tools as project_tools +from hub_core.approval_authority import ApprovalAuthorityRoot +from hub_core.mcp import GraphHubMCPServer + + +def _setup(tmp_path: Path, *, authority: ApprovalAuthorityRoot | None = None): + project = tmp_path / "LegacyGraph" + project.mkdir(parents=True) + source = project / "plot.py" + source.write_text("print('plot')\n", encoding="utf-8") + server = GraphHubMCPServer( + research_root=tmp_path, + write_tools_enabled=True, + require_host_approval=True, + host_authority_root=authority, + ) + arguments = { + "project_path": str(project), + "move_policy": "copy", + "approved_mappings": [ + {"source": "plot.py", "destination": "hub_scripts/figures/plot.py", "role": "script.figure"} + ], + } + planned = server.call_tool( + "figops.normalize_project_structure", {**arguments, "dry_run": True} + )["structuredContent"] + return server, project, arguments, planned + + +def _apply(server: GraphHubMCPServer, arguments: dict, planned: dict, **extra): + return server.call_tool( + "figops.normalize_project_structure", + { + **arguments, + "dry_run": False, + "confirmation_token": planned["confirmation_token"], + **extra, + }, + )["structuredContent"] + + +def _issue(authority: ApprovalAuthorityRoot, planned: dict, *, expires_at: str = "2030-01-01T00:00:00Z"): + return authority.issue( + planned["manifest"], + reviewer_identity="host:reviewer", + reviewer_role="principal_investigator", + issued_at="2020-01-01T00:00:00Z", + expires_at=expires_at, + ) + + +def test_secure_mode_requires_out_of_band_receipt_and_never_accepts_json(tmp_path: Path) -> None: + authority = ApprovalAuthorityRoot() + server, project, arguments, planned = _setup(tmp_path, authority=authority) + + missing = _apply(server, arguments, planned) + assert missing["status"] == "error" + assert missing["error_code"] == "FIGOPS_NORMALIZATION_HOST_APPROVAL_REQUIRED" + assert missing["approval_status"] == "rejected" + assert not (project / "hub_scripts/figures/plot.py").exists() + + record = _issue(authority, planned) + forged_with_valid_id = _apply( + server, + arguments, + planned, + approval_receipt_id=record.receipt_id, + approval={"approved": True, "reviewer": "model"}, + ) + assert forged_with_valid_id["status"] == "error" + assert forged_with_valid_id["error_code"] == "FIGOPS_NORMALIZATION_HOST_APPROVAL_REJECTED" + assert not (project / "hub_scripts/figures/plot.py").exists() + + no_root_server, no_root_project, no_root_args, no_root_plan = _setup(tmp_path / "no-root") + no_root = _apply(no_root_server, no_root_args, no_root_plan) + assert no_root["status"] == "error" + assert no_root["error_code"] == "FIGOPS_NORMALIZATION_HOST_APPROVAL_REQUIRED" + assert not (no_root_project / "hub_scripts/figures/plot.py").exists() + + +def test_secure_mode_rejects_nested_self_described_authority_before_apply(tmp_path: Path) -> None: + authority = ApprovalAuthorityRoot() + server, project, arguments, planned = _setup(tmp_path, authority=authority) + record = _issue(authority, planned) + + forged_requests = ( + {"approved_mappings": [{"approved": True}]}, + {"approved_mappings": [{"reviewer": "model"}]}, + {"config_diff": [{"path": "project.name", "signature": "forged"}]}, + {"manifest": {"trust_root": "forged"}}, + ) + for forged in forged_requests: + rejected = _apply( + server, + {**arguments, **forged}, + planned, + approval_receipt_id=record.receipt_id, + ) + assert rejected["status"] == "error" + assert rejected["error_code"] == "FIGOPS_NORMALIZATION_HOST_APPROVAL_REJECTED" + assert rejected["approval_status"] == "rejected" + assert not (project / "hub_scripts/figures/plot.py").exists() + + +def test_secure_mode_accepts_only_current_host_issued_receipt(tmp_path: Path) -> None: + authority = ApprovalAuthorityRoot() + server, project, arguments, planned = _setup(tmp_path, authority=authority) + record = _issue(authority, planned) + + applied = _apply(server, arguments, planned, approval_receipt_id=record.receipt_id) + assert applied["status"] == "ok" + assert applied["approval_status"] == "verified" + assert (project / "hub_scripts/figures/plot.py").read_text(encoding="utf-8") == "print('plot')\n" + + +def test_secure_mode_rejects_unknown_root_expired_and_revoked_receipts(tmp_path: Path) -> None: + authority = ApprovalAuthorityRoot() + server, project, arguments, planned = _setup(tmp_path, authority=authority) + other = ApprovalAuthorityRoot() + foreign = _issue(other, planned) + unknown = _apply(server, arguments, planned, approval_receipt_id=foreign.receipt_id) + assert unknown["status"] == "error" + assert not (project / "hub_scripts/figures/plot.py").exists() + + expired = _issue(authority, planned, expires_at="2020-01-02T00:00:00Z") + rejected = _apply(server, arguments, planned, approval_receipt_id=expired.receipt_id) + assert rejected["status"] == "error" + authority.revoke(expired.receipt_id) + revoked = _apply(server, arguments, planned, approval_receipt_id=expired.receipt_id) + assert revoked["status"] == "error" + assert not (project / "hub_scripts/figures/plot.py").exists() + + +def test_secure_mode_rejects_plan_mutation_before_copy(tmp_path: Path) -> None: + authority = ApprovalAuthorityRoot() + server, project, arguments, planned = _setup(tmp_path, authority=authority) + record = _issue(authority, planned) + mutated = { + **arguments, + "approved_mappings": [ + {"source": "plot.py", "destination": "results/figures/plot.py", "role": "script.figure"} + ], + } + rejected = _apply(server, mutated, planned, approval_receipt_id=record.receipt_id) + assert rejected["status"] == "error" + assert rejected["approval_status"] == "rejected" + assert not (project / "hub_scripts/figures/plot.py").exists() + assert not (project / "results/figures/plot.py").exists() + + +def test_secure_mode_rejects_config_mutation_after_host_review(tmp_path: Path) -> None: + authority = ApprovalAuthorityRoot() + server, project, arguments, planned = _setup(tmp_path, authority=authority) + record = _issue(authority, planned) + (project / "project_config.yaml").write_text("project: {name: changed}\n", encoding="utf-8") + + rejected = _apply(server, arguments, planned, approval_receipt_id=record.receipt_id) + assert rejected["status"] == "error" + assert rejected["approval_status"] == "rejected" + assert not (project / "hub_scripts/figures/plot.py").exists() + + +def test_secure_mode_rechecks_revocation_at_mutation_boundary(tmp_path: Path, monkeypatch) -> None: + authority = ApprovalAuthorityRoot() + server, project, arguments, planned = _setup(tmp_path, authority=authority) + record = _issue(authority, planned) + real_verify = project_tools.verify_approval_authority + verification_calls = 0 + + def revoke_before_boundary(current_plan, receipt_id, trusted_root): + nonlocal verification_calls + verification_calls += 1 + if verification_calls == 2: + authority.revoke(record.receipt_id) + return real_verify(current_plan, receipt_id, trusted_root) + + monkeypatch.setattr(project_tools, "verify_approval_authority", revoke_before_boundary) + rejected = _apply(server, arguments, planned, approval_receipt_id=record.receipt_id) + assert verification_calls == 2 + assert rejected["status"] == "error" + assert rejected["error_code"] == "FIGOPS_NORMALIZATION_HOST_APPROVAL_REJECTED" + assert rejected["approval_status"] == "rejected" + # The pre-apply callback is the final gate: destination parent creation, + # staging, and config replacement must all happen after it passes. + assert not (project / "hub_scripts").exists() + assert not (project / "hub_scripts/figures/plot.py").exists() + assert not (project / "project_config.yaml").exists() + + +def test_compatibility_mode_keeps_host_approval_fields_out_of_response(tmp_path: Path) -> None: + project = tmp_path / "LegacyGraph" + project.mkdir() + (project / "plot.py").write_text("print('plot')\n", encoding="utf-8") + server = GraphHubMCPServer(research_root=tmp_path, write_tools_enabled=True) + response = server.call_tool( + "figops.normalize_project_structure", + {"project_path": str(project), "dry_run": True}, + )["structuredContent"] + assert response["status"] == "ok" + assert "host_approval_required" not in response + assert "approval_status" not in response + assert "approval_receipt_id" not in response + + +def test_secure_mode_advertises_receipt_input_and_mode_outputs(tmp_path: Path) -> None: + authority = ApprovalAuthorityRoot() + server, _, _, _ = _setup(tmp_path, authority=authority) + definition = next( + item for item in server.list_tool_definitions() if item["name"] == "figops.normalize_project_structure" + ) + assert "approval_receipt_id" in definition["inputSchema"]["properties"] + output_properties = definition["outputSchema"]["properties"] + assert {"host_approval_required", "approval_status", "approval_receipt_id"} <= set(output_properties) + assert "secure mode" in definition["description"] diff --git a/tests/test_mcp_rendering.py b/tests/test_mcp_rendering.py index 58b7733..efbaff3 100644 --- a/tests/test_mcp_rendering.py +++ b/tests/test_mcp_rendering.py @@ -365,6 +365,12 @@ def test_tool_definitions_include_controlled_rendering_tools(self): self.assertIn("selected_figure", project_output) self.assertIn("snapshot_project_path", project_output) self.assertIn("provenance", project_output) + self.assertIn("policy_context", project_output) + self.assertIn("workflow_intent", project_output) + for csv_tool in ("figops.render_csv_graph", "figops.render_csv_multipanel"): + csv_output = definitions[csv_tool]["outputSchema"]["properties"] + self.assertNotIn("policy_context", csv_output) + self.assertNotIn("workflow_intent", csv_output) def test_render_csv_graph_schema_exposes_legend_axis_polish_controls(self): definitions = {tool["name"]: tool for tool in list_tool_definitions()} @@ -4642,10 +4648,24 @@ def test_schema_validity_per_tool_scoped(self): {"type": "string", "enum": ["verified", "unverified"]}, ) self.assertEqual(project_properties["promotion_eligible"]["type"], "boolean") + self.assertFalse(project_properties["policy_context"].get("additionalProperties", True)) + self.assertFalse( + project_properties["policy_context"]["properties"]["policy_set"].get("additionalProperties", True) + ) + self.assertFalse(project_properties["workflow_intent"].get("additionalProperties", True)) + self.assertFalse( + project_properties["workflow_intent"]["properties"]["provenance"].get("additionalProperties", True) + ) + for csv_tool in ("figops.render_csv_graph", "figops.render_csv_multipanel"): + csv_properties = definitions[csv_tool]["outputSchema"]["properties"] + self.assertNotIn("policy_context", csv_properties) + self.assertNotIn("workflow_intent", csv_properties) for optional_success_field in ( "claim_inventory", "publication_status", "promotion_eligible", + "policy_context", + "workflow_intent", ): self.assertNotIn(optional_success_field, project_schema.get("required", [])) with tempfile.TemporaryDirectory(prefix="graph_hub_mcp_geom_") as tmpdir: @@ -4695,6 +4715,8 @@ def test_schema_validity_per_tool_scoped(self): self._assert_validates(project_success, project_schema) self.assertIn("geometry_diagnostics", project_success) self.assertIn("layout_report", project_success) + self.assertIn("policy_context", project_success) + self.assertIn("workflow_intent", project_success) with tempfile.TemporaryDirectory(prefix="graph_hub_mcp_geom_") as tmpdir: root = Path(tmpdir) / "ResearchOS" diff --git a/tests/test_mcp_v2_tools.py b/tests/test_mcp_v2_tools.py index 90910b8..02c2c30 100644 --- a/tests/test_mcp_v2_tools.py +++ b/tests/test_mcp_v2_tools.py @@ -66,6 +66,8 @@ def test_basic_render_is_one_call_with_validated_evidence_and_lazy_uris(tmp_path validate_evidence_envelope(result["evidence"]) assert result["evidence"]["provenance"]["output_sha256"] == result["artifact"]["sha256"] assert not ({"created_paths", "job_root", "output_path", "config_path"} & set(result)) + assert "policy_context" not in result + assert "workflow_intent" not in result assert "blob" not in json.dumps(result).lower() manifest = json.loads( (tmp_path / "runtime" / "mcp_jobs" / "v2-basic" / "manifest.json").read_text(encoding="utf-8") @@ -89,6 +91,10 @@ def test_project_script_render_executes_only_declared_python_and_returns_same_co assert result["preview_uri"] == "figops://jobs/v2-project/previews/primary/0" validate_evidence_envelope(result["evidence"]) assert result["evidence"]["producer"]["kind"] == "mcp-project-script-render" + assert result["policy_context"]["schema_version"] == "figops-render-policy-context/1" + assert result["policy_context"]["render_policy"]["id"] == "render-neutral" + assert result["workflow_intent"]["schema_version"] == "figops-workflow-intent/1" + assert result["workflow_intent"]["intent"] == "execution" def test_project_rscript_missing_is_typed_and_creates_no_job( @@ -369,12 +375,25 @@ def test_all_tool_annotations_are_truthful_and_v2_schemas_fit_budgets() -> None: size = len(json.dumps(definitions[name]["inputSchema"], separators=(",", ":")).encode()) assert size <= 6 * 1024 assert len(definitions["figops.render_basic_csv"]["inputSchema"]["properties"]) <= 14 + project_properties = definitions["figops.render_project_script"]["outputSchema"]["properties"] + assert not project_properties["policy_context"].get("additionalProperties", True) + assert not project_properties["policy_context"]["properties"]["policy_set"].get( + "additionalProperties", True + ) + assert not project_properties["workflow_intent"].get("additionalProperties", True) + assert not project_properties["workflow_intent"]["properties"]["provenance"].get( + "additionalProperties", True + ) + basic_properties = definitions["figops.render_basic_csv"]["outputSchema"]["properties"] + assert "policy_context" not in basic_properties + assert "workflow_intent" not in basic_properties def test_wp5_touched_modules_stay_below_modularity_gate() -> None: root = Path(__file__).parents[1] for relative in ( "hub_core/mcp/schemas.py", + "hub_core/mcp/phase2_render_schemas.py", "hub_core/mcp/tool_schema_common.py", "hub_core/mcp/v2_tool_schemas.py", "hub_core/mcp/render_orchestration.py", diff --git a/tests/test_mcp_write_gating.py b/tests/test_mcp_write_gating.py new file mode 100644 index 0000000..7e5335a --- /dev/null +++ b/tests/test_mcp_write_gating.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from hub_core.human_review_receipt import ( + build_review_subject, + canonical_human_review_receipt_bytes, + opaque_figure_artifact_id, + opaque_project_id, +) +from hub_core.mcp import FigOpsMCPServer +from hub_core.promotion_gate_receipt import canonical_promotion_gate_receipt_bytes +from hub_core.promotion_gate_recording import ( + PromotionGateRecordingError, + discard_promotion_gate_receipt, + record_promotion_gate_receipt, +) +from tests.human_review_receipt_helpers import receipt +from tests.test_mcp_rendering import _write_project_render_fixture +from tests.test_result_promotion_integration import _gate_receipt + + +def _review(project_name: str = "Project Render Fixture", figure_id: str = "Fig1") -> dict[str, object]: + subject = build_review_subject( + project_id=opaque_project_id(project_name), + artifact_id=opaque_figure_artifact_id(figure_id), + artifact_sha256="1" * 64, + lineage_receipt_sha256="2" * 64, + evidence_digest="3" * 64, + resolved_policy_digest="4" * 64, + decision_scope="figure_scientific_and_communication", + ) + return receipt(subject=subject) + + +def _server(root: Path, *, writes: bool) -> FigOpsMCPServer: + return FigOpsMCPServer( + research_root=root, + runtime_root=root / "runtime", + write_tools_enabled=writes, + ) + + +def _arguments( + project: Path, + review: dict[str, object], + *, + figure_id: str = "Fig1", + **extra: object, +) -> dict[str, object]: + return { + "project_path": str(project), + "figure_id": figure_id, + "relative_path": "human/review.json", + "review_receipt": review, + **extra, + } + + +def test_review_writer_is_write_gated_and_not_discovered_by_frozen_profiles(tmp_path: Path) -> None: + project = _write_project_render_fixture(tmp_path) + review = _review() + disabled = _server(tmp_path, writes=False) + + response = disabled.call_tool("figops.record_human_review", _arguments(project, review)) + assert response["isError"] is True + assert response["structuredContent"]["error_category"] == "disabled" + assert not (project / "results" / "evidence").exists() + assert "figops.record_human_review" not in { + item["name"] for item in disabled.list_tool_definitions() + } + + +def test_enabled_review_writer_records_exact_canonical_bytes_and_binds_subject(tmp_path: Path) -> None: + project = _write_project_render_fixture(tmp_path) + review = _review() + server = _server(tmp_path, writes=True) + + response = server.call_tool("figops.record_human_review", _arguments(project, review)) + result = response["structuredContent"] + destination = project / "results" / "evidence" / "human" / "review.json" + assert response["isError"] is False + assert result["receipt_id"] == review["receipt_id"] + assert destination.read_bytes() == canonical_human_review_receipt_bytes(review) + + +def test_review_writer_rejects_subject_mismatch_without_creating_evidence(tmp_path: Path) -> None: + project = _write_project_render_fixture(tmp_path) + server = _server(tmp_path, writes=True) + + wrong_project = server.call_tool( + "figops.record_human_review", _arguments(project, _review("Other Project")) + ) + wrong_artifact = server.call_tool( + "figops.record_human_review", _arguments(project, _review(figure_id="OtherFigure")) + ) + unconfigured_figure = server.call_tool( + "figops.record_human_review", + _arguments(project, _review(figure_id="OtherFigure"), figure_id="OtherFigure"), + ) + assert wrong_project["isError"] is True + assert wrong_artifact["isError"] is True + assert unconfigured_figure["isError"] is True + assert not (project / "results" / "evidence").exists() + + +def test_review_writer_rejects_escape_self_described_authority_and_no_clobber(tmp_path: Path) -> None: + project = _write_project_render_fixture(tmp_path) + server = _server(tmp_path, writes=True) + review = _review() + + escaped = server.call_tool( + "figops.record_human_review", _arguments(project, review, relative_path="../outside.json") + ) + backslash = server.call_tool( + "figops.record_human_review", _arguments(project, review, relative_path="human\\review.json") + ) + forged = server.call_tool( + "figops.record_human_review", _arguments(project, review, approval={"approved": True}) + ) + assert escaped["isError"] is True + assert backslash["isError"] is True + assert forged["isError"] is True + assert not (tmp_path / "outside.json").exists() + + destination = project / "results" / "evidence" / "human" / "review.json" + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(b"winner") + raced = server.call_tool("figops.record_human_review", _arguments(project, review)) + assert raced["isError"] is True + assert destination.read_bytes() == b"winner" + + +def test_gate_receipt_rollback_removes_owned_file(tmp_path: Path) -> None: + gate = _gate_receipt(gate_status="blocked") + result = record_promotion_gate_receipt( + gate, + evidence_root=tmp_path / "evidence", + relative_path="figure.promotion-gate.json", + ) + destination = tmp_path / "evidence" / result.relative_path + assert destination.exists() + + if os.name == "nt": + discard_promotion_gate_receipt(result, evidence_root=tmp_path / "evidence") + assert not destination.exists() + else: + with pytest.raises(PromotionGateRecordingError, match="ambiguous"): + discard_promotion_gate_receipt(result, evidence_root=tmp_path / "evidence") + assert destination.read_bytes() == canonical_promotion_gate_receipt_bytes(gate) + + +def test_gate_receipt_rollback_preserves_replaced_inode(tmp_path: Path) -> None: + receipt = _gate_receipt(gate_status="blocked") + result = record_promotion_gate_receipt( + receipt, + evidence_root=tmp_path / "evidence", + relative_path="figure.promotion-gate.json", + ) + destination = tmp_path / "evidence" / result.relative_path + replacement = destination.with_name("replacement.json") + replacement.write_bytes(b"competitor") + os.replace(replacement, destination) + + with pytest.raises(PromotionGateRecordingError, match="ownership"): + discard_promotion_gate_receipt(result, evidence_root=tmp_path / "evidence") + assert destination.read_bytes() == b"competitor" diff --git a/tests/test_orchestrator_structure_audit.py b/tests/test_orchestrator_structure_audit.py new file mode 100644 index 0000000..54ad973 --- /dev/null +++ b/tests/test_orchestrator_structure_audit.py @@ -0,0 +1,126 @@ +"""CLI coverage for the independent, read-only structure audit mode.""" + +from __future__ import annotations + +import contextlib +import io +import json +import sys +import types +from pathlib import Path +from unittest.mock import patch + +import hub_core +import hub_core.structure_audit_report # noqa: F401 # ensure package attribute exists for the test double +import orchestrator + + +def _report_module(build_calls: list[tuple[object, int]], render_calls: list[str]) -> types.ModuleType: + module = types.ModuleType("hub_core.structure_audit_report") + + def build_structure_audit_report(root_dir: object, *, max_depth: int = 4) -> dict[str, object]: + build_calls.append((root_dir, max_depth)) + return {"schema_version": "test", "projects": []} + + def render_structure_audit_report(report: dict[str, object], *, output_format: str = "markdown") -> str: + del report + render_calls.append(output_format) + if output_format == "json": + return json.dumps({"format": output_format}) + return "# Structure audit\n" + + module.build_structure_audit_report = build_structure_audit_report + module.render_structure_audit_report = render_structure_audit_report + return module + + +def test_audit_structure_json_is_independent_and_emits_stdout_only_report(tmp_path: Path) -> None: + build_calls: list[tuple[object, int]] = [] + render_calls: list[str] = [] + report_module = _report_module(build_calls, render_calls) + stdout = io.StringIO() + stderr = io.StringIO() + + with ( + patch.object( + sys, + "argv", + ["orchestrator.py", "--audit-structure", "--audit-structure-format", "json", "--scan-depth", "2"], + ), + patch("orchestrator.get_hub_path", return_value=str(tmp_path / "hub")), + patch("orchestrator.get_research_root", return_value=str(tmp_path)), + patch.object(hub_core, "structure_audit_report", report_module), + patch.dict(sys.modules, {"hub_core.structure_audit_report": report_module}), + patch("orchestrator.run_analysis") as run_analysis, + patch("orchestrator.run_plots") as run_plots, + patch("orchestrator.run_check_all") as run_check_all, + contextlib.redirect_stdout(stdout), + contextlib.redirect_stderr(stderr), + ): + result = orchestrator.main() + + assert result == 0 + assert json.loads(stdout.getvalue()) == {"format": "json"} + assert build_calls == [(str(tmp_path), 2)] + assert render_calls == ["json"] + run_analysis.assert_not_called() + run_plots.assert_not_called() + run_check_all.assert_not_called() + + +def test_audit_structure_defaults_to_markdown_and_does_not_touch_project_files(tmp_path: Path) -> None: + build_calls: list[tuple[object, int]] = [] + render_calls: list[str] = [] + report_module = _report_module(build_calls, render_calls) + project_file = tmp_path / "project_config.yaml" + project_file.write_text("project:\n name: untouched\n", encoding="utf-8") + original = project_file.read_bytes() + + with ( + patch.object(sys, "argv", ["orchestrator.py", "--audit-structure"]), + patch("orchestrator.get_hub_path", return_value=str(tmp_path / "hub")), + patch("orchestrator.get_research_root", return_value=str(tmp_path)), + patch.object(hub_core, "structure_audit_report", report_module), + patch.dict(sys.modules, {"hub_core.structure_audit_report": report_module}), + ): + result = orchestrator.main() + + assert result == 0 + assert render_calls == ["markdown"] + assert project_file.read_bytes() == original + + +def test_audit_structure_rejects_execution_and_selector_modes(tmp_path: Path) -> None: + build_calls: list[tuple[object, int]] = [] + render_calls: list[str] = [] + report_module = _report_module(build_calls, render_calls) + + for extra in (("--project", "module"), ("--check-all",), ("--list-projects",)): + stdout = io.StringIO() + with ( + patch.object(sys, "argv", ["orchestrator.py", "--audit-structure", *extra]), + patch("orchestrator.get_hub_path", return_value=str(tmp_path / "hub")), + patch("orchestrator.get_research_root", return_value=str(tmp_path)), + patch.object(hub_core, "structure_audit_report", report_module), + patch.dict(sys.modules, {"hub_core.structure_audit_report": report_module}), + contextlib.redirect_stdout(stdout), + ): + result = orchestrator.main() + + assert result == 1 + assert "independent read-only mode" in stdout.getvalue() + + assert build_calls == [] + assert render_calls == [] + + +def test_audit_structure_format_requires_audit_mode() -> None: + stdout = io.StringIO() + with ( + patch.object(sys, "argv", ["orchestrator.py", "--audit-structure-format", "json"]), + contextlib.redirect_stdout(stdout), + ): + result = orchestrator.main() + + assert result == 1 + assert "requires --audit-structure" in stdout.getvalue() diff --git a/tests/test_phase4_structure_contract_regressions.py b/tests/test_phase4_structure_contract_regressions.py new file mode 100644 index 0000000..87c7469 --- /dev/null +++ b/tests/test_phase4_structure_contract_regressions.py @@ -0,0 +1,153 @@ +"""Focused Phase 4 regression coverage for reviewed structure operations.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from hub_core.mcp import GraphHubMCPServer +from hub_core.project_normalization import plan_normalize_project +from hub_core.structure_path_security import capture_project_root +from hub_core.structure_plan import ( + build_structure_plan, + canonical_plan_digest, + confirmation_token, +) +from hub_core.structure_role_binding import validate_role_destination_bindings + + +def _tree_snapshot(root: Path) -> tuple[str, ...]: + return tuple( + sorted( + path.relative_to(root).as_posix() + for path in root.rglob("*") + if "__pycache__" not in path.parts + ) + ) + + +def _structured(response: dict) -> dict: + return response["structuredContent"] + + +def test_plan_digest_and_token_are_stable_for_semantically_identical_plans(tmp_path: Path) -> None: + (tmp_path / "legacy").mkdir() + (tmp_path / "legacy" / "a.csv").write_bytes(b"a\n1\n") + (tmp_path / "legacy" / "b.csv").write_bytes(b"b\n2\n") + mappings = [ + {"source": "legacy/a.csv", "destination": "raw/a.csv", "role": "raw"}, + {"source": "legacy/b.csv", "destination": "raw/b.csv", "role": "raw"}, + ] + + first = build_structure_plan(tmp_path, mappings) + second = build_structure_plan(tmp_path, list(reversed(mappings))) + + assert canonical_plan_digest(first) == canonical_plan_digest(second) + assert first["digest"] == second["digest"] + assert confirmation_token(first) == confirmation_token(second) + + # Presentation-only dictionary ordering must not change the reviewed identity. + reordered = json.loads(json.dumps(second, ensure_ascii=False)) + reordered = {key: reordered[key] for key in reversed(list(reordered))} + assert canonical_plan_digest(first) == canonical_plan_digest(reordered) + + +@pytest.mark.parametrize( + "malformed", + [ + {"path": "structure.roots.raw", "before": "raw", "after": "raw"}, + {"path": [], "before": "raw", "after": "raw"}, + {"path": ["structure", True], "before": "raw", "after": "raw"}, + {"path": ["structure", "roots", "raw"], "before": "raw"}, + ], +) +def test_build_structure_plan_rejects_malformed_config_diff(tmp_path: Path, malformed: dict) -> None: + source = tmp_path / "legacy" / "input.csv" + source.parent.mkdir() + source.write_bytes(b"x\n1\n") + + with pytest.raises(ValueError): + build_structure_plan( + tmp_path, + [{"source": "legacy/input.csv", "destination": "raw/input.csv", "role": "raw"}], + config_diff=[malformed], + ) + + +def test_validate_role_destination_bindings_rejects_unknown_role(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="unknown structure role"): + validate_role_destination_bindings( + tmp_path, + [{"role": "script.unknown", "destination": "hub_scripts/analysis/x.py"}], + config_path=tmp_path / "project_config.yaml", + config_update=None, + root_identity=capture_project_root(tmp_path), + planned_hash=None, + ) + + +@pytest.mark.parametrize("destination", ["hub_scripts/figures/x.py", "outside/x.py"]) +def test_validate_role_destination_bindings_rejects_sibling_or_outside_destination( + tmp_path: Path, destination: str +) -> None: + with pytest.raises(ValueError, match="not bound"): + validate_role_destination_bindings( + tmp_path, + [{"role": "script.analysis", "destination": destination}], + config_path=tmp_path / "project_config.yaml", + config_update=None, + root_identity=capture_project_root(tmp_path), + planned_hash=None, + ) + + +def test_plan_normalize_copy_requires_approvals_but_adopt_is_read_only(tmp_path: Path) -> None: + source = tmp_path / "plot.py" + source.write_text("print('plot')\n", encoding="utf-8") + before = _tree_snapshot(tmp_path) + + with pytest.raises(ValueError, match="approved_mappings"): + plan_normalize_project(project_path=tmp_path, move_policy="copy") + + adopted = plan_normalize_project(project_path=tmp_path, move_policy="adopt") + + assert adopted["adopt_existing"] is True + assert adopted["entries"] == [] + assert _tree_snapshot(tmp_path) == before + + +def test_normalize_project_structure_apply_requires_exact_confirmation_token(tmp_path: Path) -> None: + project = tmp_path / "LegacyGraph" + project.mkdir() + (project / "plot.py").write_text("print('plot')\n", encoding="utf-8") + server = GraphHubMCPServer(research_root=tmp_path) + arguments = { + "project_path": str(project), + "move_policy": "copy", + "approved_mappings": [ + {"source": "plot.py", "destination": "hub_scripts/figures/plot.py", "role": "script.figure"} + ], + } + + planned = _structured(server.call_tool("figops.normalize_project_structure", {**arguments, "dry_run": True})) + rejected = _structured( + server.call_tool( + "figops.normalize_project_structure", + {**arguments, "dry_run": False, "confirmation_token": planned["confirmation_token"] + "x"}, + ) + ) + + assert rejected["status"] == "error" + assert rejected["error_code"] == "FIGOPS_NORMALIZATION_PLAN_REJECTED" + assert not (project / "hub_scripts" / "figures" / "plot.py").exists() + + applied = _structured( + server.call_tool( + "figops.normalize_project_structure", + {**arguments, "dry_run": False, "confirmation_token": planned["confirmation_token"]}, + ) + ) + assert applied["status"] in {"ok", "warning"} + assert (project / "hub_scripts" / "figures" / "plot.py").read_text(encoding="utf-8") == "print('plot')\n" diff --git a/tests/test_phase5_dependency_integration.py b/tests/test_phase5_dependency_integration.py new file mode 100644 index 0000000..ab46e43 --- /dev/null +++ b/tests/test_phase5_dependency_integration.py @@ -0,0 +1,102 @@ +"""Focused Phase 5 coverage for normalization dependency evidence.""" + +from __future__ import annotations + +from pathlib import Path + +from hub_core.project_normalization import plan_normalize_project + + +def _tree_snapshot(root: Path) -> tuple[str, ...]: + return tuple(sorted(path.relative_to(root).as_posix() for path in root.rglob("*"))) + + +def _approved_analysis_mapping() -> list[dict[str, str]]: + return [ + { + "source": "scripts/analysis.py", + "destination": "hub_scripts/analysis/analysis.py", + "role": "script.analysis", + } + ] + + +def test_copy_plan_reports_hardcoded_dependency_for_approved_script(tmp_path: Path) -> None: + script = tmp_path / "scripts" / "analysis.py" + script.parent.mkdir() + script.write_text("frame = read_csv('data/input.csv')\n", encoding="utf-8") + + plan = plan_normalize_project( + project_path=tmp_path, + move_policy="copy", + approved_mappings=_approved_analysis_mapping(), + ) + + blockers = plan["hardcoded_unresolved_references"] + assert any( + blocker.get("kind") == "hardcoded_path" + and blocker.get("path") == "data/input.csv" + and blocker.get("script") == "scripts/analysis.py" + for blocker in blockers + ) + + +def test_adopt_plan_is_read_only_while_previewing_script_dependencies(tmp_path: Path) -> None: + script = tmp_path / "scripts" / "analysis.py" + script.parent.mkdir() + script.write_text("frame = read_csv('data/input.csv')\n", encoding="utf-8") + before = _tree_snapshot(tmp_path) + + plan = plan_normalize_project(project_path=tmp_path, move_policy="adopt") + + assert plan["adopt_existing"] is True + assert plan["entries"] == [] + assert plan["proposed_mappings"] + assert _tree_snapshot(tmp_path) == before + + +def test_declared_raw_role_root_resolves_script_data_dependency(tmp_path: Path) -> None: + script = tmp_path / "scripts" / "analysis.py" + script.parent.mkdir() + script.write_text("frame = read_csv('data/input.csv')\n", encoding="utf-8") + (tmp_path / "project_config.yaml").write_text( + "structure:\n roots:\n raw: data\n", + encoding="utf-8", + ) + + plan = plan_normalize_project( + project_path=tmp_path, + move_policy="copy", + approved_mappings=_approved_analysis_mapping(), + ) + + assert plan["hardcoded_unresolved_references"] == [] + + +def test_unsupported_script_language_is_a_dependency_scan_blocker(tmp_path: Path) -> None: + script = tmp_path / "scripts" / "analysis.jl" + script.parent.mkdir() + script.write_text("CSV.read(\"data/input.csv\")\n", encoding="utf-8") + + plan = plan_normalize_project( + project_path=tmp_path, + move_policy="copy", + approved_mappings=[ + { + "source": "scripts/analysis.jl", + "destination": "hub_scripts/analysis/analysis.jl", + "role": "script.analysis", + } + ], + ) + + assert any( + blocker.get("kind") == "unsupported_language" + and blocker.get("script") == "scripts/analysis.jl" + for blocker in plan["hardcoded_unresolved_references"] + ) + assert any( + blocker.get("kind") == "dependency_scan_incomplete" + and blocker.get("script") == "scripts/analysis.jl" + for blocker in plan["hardcoded_unresolved_references"] + ) diff --git a/tests/test_policy_resolution.py b/tests/test_policy_resolution.py new file mode 100644 index 0000000..b0b4d31 --- /dev/null +++ b/tests/test_policy_resolution.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import hashlib +from dataclasses import FrozenInstanceError + +import pytest + +from hub_core.policy_resolution import ( + PolicyResolutionError, + compatibility_resolved_policy, + parse_policy_layers_json, + resolve_policy_set, +) + + +def _layer(source: str, parameters: dict, policy_id: str | None = None) -> dict: + return { + "source": source, + "policy_id": policy_id or f"{source}-policy", + "version": "1", + "parameters": parameters, + } + + +KERNEL_INVARIANTS = ( + "path_containment", + "schema_receipt_integrity", + "runtime_result_disjointness", + "no_replace_promotion", +) + + +def test_defaults_are_canonical_digestible_and_immutable() -> None: + policy = resolve_policy_set([]) + render = policy.value("render_policy") + + assert render.value == "neutral" + assert policy.value("path_containment").value is True + assert policy.value("validation_target").value is None + assert policy.canonical_sha256() == hashlib.sha256(policy.canonical_bytes()).hexdigest() + with pytest.raises(FrozenInstanceError): + render.value = "nature" # type: ignore[misc] + + +def test_render_validation_axes_project_to_legacy_resolved_policy() -> None: + policy = resolve_policy_set( + [_layer("render", {"render_policy": "neutral", "validation_target": "nature"})] + ) + + assert compatibility_resolved_policy(policy) == { + "id": "journal-nature", + "version": "1", + "source": "policy-set-compatibility-projection", + "parameters": {"render_policy": "render-neutral", "validation_target": "nature"}, + } + + +def test_research_ops_false_opt_out_preserves_current_project_fact() -> None: + policy = resolve_policy_set( + [_layer("project", {"require_figure_traceability": {"value": False, "opt_out": True}})] + ) + traceability = policy.value("require_figure_traceability") + + assert traceability.value is False + assert traceability.source == "project" + assert traceability.opt_out_requested is True + assert traceability.opt_out_accepted is True + assert policy.value("require_canonical_docs").value is True + + +def test_research_ops_opt_out_only_records_explicit_source_and_digest_fact() -> None: + default = resolve_policy_set([]) + policy = resolve_policy_set( + [_layer("project", {"require_canonical_docs": {"opt_out": True}}, "project-config-policy")] + ) + canonical_docs = policy.value("require_canonical_docs") + + assert canonical_docs.value is False + assert canonical_docs.source == "explicit_project_opt_out" + assert canonical_docs.policy_id == "project-config-policy" + assert canonical_docs.opt_out_requested is True + assert canonical_docs.opt_out_accepted is True + assert canonical_docs.constraints[0].source == "explicit_project_opt_out" + assert canonical_docs.constraints[0].value is False + assert b"explicit_project_opt_out" in policy.canonical_bytes() + assert policy.canonical_sha256() != default.canonical_sha256() + + +def test_higher_require_blocks_lower_opt_out_without_silent_override() -> None: + policy = resolve_policy_set( + [ + _layer("operator", {"require_figure_traceability": True}), + _layer("project", {"require_figure_traceability": {"value": False, "opt_out": True}}), + ] + ) + traceability = policy.value("require_figure_traceability") + + assert traceability.value is True + assert traceability.source == "operator" + assert traceability.opt_out_requested is True + assert traceability.opt_out_accepted is False + + +def test_kernel_invariants_are_not_opt_out_capable() -> None: + with pytest.raises(PolicyResolutionError, match="path_containment immutable kernel invariant"): + resolve_policy_set([_layer("project", {"path_containment": {"value": False, "opt_out": True}})]) + + +@pytest.mark.parametrize("invariant", KERNEL_INVARIANTS) +@pytest.mark.parametrize( + "disable_form", + [ + False, + {"value": False}, + {"value": False, "opt_out": True}, + ], +) +def test_kernel_invariants_reject_all_false_disable_forms(invariant: str, disable_form: object) -> None: + with pytest.raises(PolicyResolutionError, match="immutable kernel invariant cannot be disabled"): + resolve_policy_set([_layer("project", {invariant: disable_form})]) + + +@pytest.mark.parametrize("invariant", KERNEL_INVARIANTS) +def test_kernel_invariants_reject_opt_out_only_disable_form(invariant: str) -> None: + with pytest.raises(PolicyResolutionError, match="does not allow opt-out"): + resolve_policy_set([_layer("project", {invariant: {"opt_out": True}})]) + + +def test_selection_allowed_sets_only_narrow_and_conflict_fail_closed() -> None: + with pytest.raises(PolicyResolutionError, match="empty allowed-set intersection"): + resolve_policy_set( + [ + _layer("operator", {"render_policy": {"allowed": ["nature"]}}), + _layer("project", {"render_policy": {"allowed": ["nature", "science"]}}), + _layer("render", {"render_policy": "science"}), + ] + ) + + +def test_exact_and_equal_source_conflicts_fail_closed() -> None: + with pytest.raises(PolicyResolutionError, match="exact values conflict"): + resolve_policy_set( + [ + _layer("lab", {"project_role": "module"}), + _layer("project", {"project_role": "legacy"}), + ] + ) + with pytest.raises(PolicyResolutionError, match="duplicate policy source"): + resolve_policy_set([_layer("project", {}), _layer("project", {}, "project-policy-2")]) + + +def test_all_merge_operators_are_deterministic() -> None: + policy = resolve_policy_set( + [ + _layer( + "operator", + { + "minimum_raster_dpi": 300, + "maximum_physical_width_mm": 180, + "allowed_artifact_formats": {"allowed": ["png", "pdf"]}, + }, + ), + _layer( + "project", + { + "minimum_raster_dpi": 600, + "maximum_physical_width_mm": 120, + "allowed_artifact_formats": {"allowed": ["png"]}, + }, + ), + ] + ) + + assert policy.value("minimum_raster_dpi").value == 600 + assert policy.value("maximum_physical_width_mm").value == 120 + assert policy.value("allowed_artifact_formats").value == ["png"] + + +def test_unknown_duplicate_nonfinite_and_path_like_inputs_are_rejected() -> None: + with pytest.raises(PolicyResolutionError, match="duplicate key"): + parse_policy_layers_json( + b'[{"source":"project","source":"project","policy_id":"p","version":"1","parameters":{}}]' + ) + with pytest.raises(PolicyResolutionError, match="non-finite"): + resolve_policy_set( + b'[{"source":"project","policy_id":"p","version":"1","parameters":{"minimum_raster_dpi":NaN}}]' + ) + with pytest.raises(PolicyResolutionError, match="unsupported or missing"): + resolve_policy_set([_layer("project", {"unknown_axis": True})]) + with pytest.raises(PolicyResolutionError, match="path-like"): + resolve_policy_set([_layer("project", {"project_role": "C:/research/project"})]) + + +def test_exceptions_are_recorded_but_do_not_change_resolved_values() -> None: + policy = resolve_policy_set( + [ + _layer( + "project", + { + "require_canonical_docs": { + "value": True, + "exceptions": [ + {"finding_code": "CANONICAL_DOC_EVIDENCE_INVALID", "subject_digest": "a" * 64} + ], + } + }, + ) + ] + ) + canonical_docs = policy.value("require_canonical_docs") + + assert canonical_docs.value is True + assert canonical_docs.exceptions[0].finding_code == "CANONICAL_DOC_EVIDENCE_INVALID" + assert canonical_docs.exceptions[0].subject_digest == "a" * 64 + + +def test_canonical_digest_is_stable_for_equivalent_mapping_order() -> None: + left = resolve_policy_set([_layer("render", {"render_policy": "neutral", "validation_target": "nature"})]) + right = resolve_policy_set([_layer("render", {"validation_target": "nature", "render_policy": "neutral"})]) + + assert left.canonical_bytes() == right.canonical_bytes() + assert left.canonical_sha256() == right.canonical_sha256() diff --git a/tests/test_production_launcher_approval.py b/tests/test_production_launcher_approval.py new file mode 100644 index 0000000..4229aec --- /dev/null +++ b/tests/test_production_launcher_approval.py @@ -0,0 +1,155 @@ +"""Contract tests for the secure production MCP entry point. + +The historical :class:`GraphHubMCPServer` constructor remains available for +compatibility clients. The graphhub/figops stdio entry point has a separate +factory so that production cannot accidentally inherit the compatibility +server's token-only approval mode. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import patch + +import graphhub_mcp_server +import hub_core.mcp as mcp_module +from hub_core.approval_authority import ApprovalAuthorityRoot +from hub_core.mcp import FigOpsMCPServer, GraphHubMCPServer, McpServerConfig + + +def _config(root: Path, **overrides: object) -> McpServerConfig: + """Build an explicit, side-effect-free config for factory tests.""" + + values: dict[str, object] = { + "hub_path": root, + "research_root": root, + "runtime_root": root / "runtime", + } + values.update(overrides) + return McpServerConfig.from_mapping(values) + + +def _assert_secure_production_server(server: object) -> None: + """Assert the non-negotiable trust boundary of the production factory.""" + + assert type(server) is FigOpsMCPServer + assert server.require_host_approval is True + assert type(server.host_authority_root) is ApprovalAuthorityRoot + assert server.host_authority_root is server.host_authority_index + + +def test_production_factory_returns_secure_figops_server(tmp_path: Path) -> None: + config = _config(tmp_path) + + server = graphhub_mcp_server._build_production_server(config) + _assert_secure_production_server(server) + + +def test_production_factory_cannot_be_downgraded_by_config_or_env( + tmp_path: Path, + monkeypatch, +) -> None: + # ``require_host_approval`` is deliberately not an operator/config value; + # retain this adversarial key to prove a false value cannot reach the + # production trust boundary. A compatibility surface and write opt-in + # likewise must not select GraphHubMCPServer or token-only mode. + config = McpServerConfig.from_mapping( + { + "hub_path": tmp_path, + "research_root": tmp_path, + "runtime_root": tmp_path / "runtime", + "surface_profile": "compatibility", + "write_tools_enabled": True, + "require_host_approval": False, + } + ) + monkeypatch.setenv("GRAPH_HUB_MCP_REQUIRE_HOST_APPROVAL", "0") + monkeypatch.setenv("GRAPH_HUB_MCP_SURFACE_PROFILE", "compatibility") + monkeypatch.setenv("GRAPH_HUB_MCP_WRITE_TOOLS_ENABLED", "1") + + server = graphhub_mcp_server._build_production_server(config) + + _assert_secure_production_server(server) + + # Exercise the real environment parser as well. None of the supported + # environment fields is allowed to become a secure-mode opt-out. + env_config = McpServerConfig.from_env().overlay( + hub_path=tmp_path, + research_root=tmp_path, + runtime_root=tmp_path / "runtime-env", + write_tools_enabled=True, + ) + env_server = graphhub_mcp_server._build_production_server(env_config) + _assert_secure_production_server(env_server) + + +def test_smoke_constructs_server_through_secure_production_factory(tmp_path: Path) -> None: + config = _config(tmp_path) + calls: list[tuple[object, tuple[object, ...], dict[str, object]]] = [] + + class SmokeServer: + def call_tool(self, name: str, arguments: dict[str, object]) -> dict[str, object]: + if name == "figops.health": + return {"structuredContent": {"status": "ok"}} + if name == "figops.list_styles": + return {"structuredContent": {"status": "ok", "target_formats": ["default"]}} + raise AssertionError(f"unexpected smoke tool: {name}") + + def fake_factory(config: object, *args: object, **kwargs: object) -> SmokeServer: + assert not args + calls.append((config, (), kwargs)) + return SmokeServer() + + with ( + patch.object(graphhub_mcp_server, "run_doctor", return_value={"checks": []}), + patch.object(graphhub_mcp_server, "_build_production_server", side_effect=fake_factory), + ): + result = graphhub_mcp_server._run_smoke(config) + + assert result == 0 + assert len(calls) == 1 + assert calls[0][0] is config + # Smoke is read-only, but it must still use the production factory. If + # the factory accepts this knob, it should disable MCP initialize framing; + # an omitted knob is also valid for a factory whose default is read-only. + assert calls[0][2].get("require_initialize", False) is False + + +def test_stdio_cli_routes_through_secure_production_factory(tmp_path: Path) -> None: + sentinel_server = object() + calls: list[tuple[object, tuple[object, ...], dict[str, object]]] = [] + + def fake_factory(config: object, *args: object, **kwargs: object) -> object: + calls.append((config, args, kwargs)) + return sentinel_server + + argv = [ + "graphhub_mcp_server.py", + "--hub-path", + str(tmp_path), + "--research-root", + str(tmp_path), + "--runtime-root", + str(tmp_path / "runtime"), + ] + with ( + patch.object(sys, "argv", argv), + patch.object(graphhub_mcp_server, "_build_production_server", side_effect=fake_factory), + patch.object(mcp_module, "run_stdio_server", return_value=23) as run_stdio, + ): + result = graphhub_mcp_server.main() + + assert result == 23 + assert len(calls) == 1 + assert calls[0][2].get("require_initialize") is True + run_stdio.assert_called_once_with(sentinel_server) + + +def test_direct_graphhub_server_remains_compatibility_token_only(tmp_path: Path) -> None: + server = GraphHubMCPServer(config=_config(tmp_path, surface_profile="compatibility")) + + assert type(server) is GraphHubMCPServer + assert server.surface_profile == "compatibility" + assert server.require_host_approval is False + assert server.host_authority_root is None diff --git a/tests/test_promotion_gate.py b/tests/test_promotion_gate.py new file mode 100644 index 0000000..cfe915d --- /dev/null +++ b/tests/test_promotion_gate.py @@ -0,0 +1,622 @@ +from __future__ import annotations + +import hashlib +import json +from typing import Any + +import pytest + +from hub_core.durable_receipt import DurableReceipt, opaque_artifact_id, opaque_claim_id, opaque_receipt_id +from hub_core.human_review_receipt import ( + HumanReviewAuthorityBinding, + HumanReviewVerificationPolicy, + build_human_review_receipt, + build_review_subject, + build_reviewer, + opaque_figure_artifact_id, + opaque_principal_id, + opaque_project_id, +) +from hub_core.policy_resolution import resolve_policy_set +from hub_core.promotion_gate import GATE_CODE_ORDER, evaluate_promotion_gate +from hub_core.promotion_gate_receipt import ( + PromotionGateReceiptError, + build_promotion_gate_receipt, + canonical_promotion_gate_receipt_bytes, + promotion_gate_digest, + validate_promotion_gate_receipt, +) +from hub_core.workflow_intent import infer_workflow_intent + +ARTIFACT_SHA = "1" * 64 +EVIDENCE_SHA = "2" * 64 +DEFAULT_SCOPE = "figure_scientific_and_communication" +DEFAULT_AUTHORITY = "lab-policy/1" + + +def _layer(source: str, parameters: dict[str, Any]) -> dict[str, Any]: + return { + "source": source, + "policy_id": f"{source}-policy", + "version": "1", + "parameters": parameters, + } + + +def _candidate(artifact_sha: str = ARTIFACT_SHA) -> dict[str, str]: + return { + "project_id": opaque_project_id("project-17"), + "artifact_id": opaque_figure_artifact_id("figure-1.png"), + "role": "result.figure", + "sha256": artifact_sha, + } + + +def _manifest(artifact_sha: str = ARTIFACT_SHA) -> dict[str, Any]: + return { + "publication_status": "verified", + "promotion_eligible": True, + "manual_review_needed": False, + "evidence": { + "artifacts": { + "entries": [ + { + "logical_role": "primary", + "sha256": artifact_sha, + } + ] + } + }, + "claim_inventory": { + "schema_version": "figops_claim_inventory/1", + "status": "verified", + "promotion_eligible": True, + "manual_review_needed": False, + "errors": [], + "claims": [], + "explicit_no_claims": True, + }, + } + + +def _lineage(artifact_sha: str = ARTIFACT_SHA) -> DurableReceipt: + figure = { + "artifact_id": opaque_artifact_id("result.figure", "figure-1.png"), + "role": "result.figure", + "sha256": artifact_sha, + } + return DurableReceipt( + figops_version="0.20.0", + run_id=opaque_receipt_id("run", "job-1"), + timestamp="2026-07-20T00:00:00Z", + git_sha256="3" * 64, + config_sha256="4" * 64, + script_sha256="5" * 64, + environment_lock_sha256="6" * 64, + durable_artifact=figure, + input_artifacts=[ + { + "artifact_id": opaque_artifact_id("raw", "input-set"), + "role": "raw", + "sha256": "7" * 64, + } + ], + output_artifacts=[figure], + claim_ids=[opaque_claim_id("explicit-no-claims")], + ) + + +def _policy(*, human_signoff_required: bool): + return resolve_policy_set([_layer("project", {"human_signoff_required": human_signoff_required})]) + + +def _readiness(status: str = "needs_review", evidence_sha: str = EVIDENCE_SHA) -> dict[str, Any]: + return { + "schema_version": "publication_readiness/1", + "readiness_status": status, + "evidence_digest": evidence_sha, + "manual_review_required": True, + "gates": [], + "findings": [], + } + + +def _review_policy() -> HumanReviewVerificationPolicy: + return HumanReviewVerificationPolicy( + allow_local_attestation=True, + reviewer_bindings=frozenset( + { + HumanReviewAuthorityBinding( + decision_scope=DEFAULT_SCOPE, + reviewer_role="scientific_reviewer", + authority_assertion=DEFAULT_AUTHORITY, + ) + } + ), + ) + + +def _review(candidate: dict[str, str], lineage: DurableReceipt, policy) -> dict[str, Any]: + subject = build_review_subject( + project_id=candidate["project_id"], + artifact_id=candidate["artifact_id"], + artifact_sha256=candidate["sha256"], + lineage_receipt_sha256=lineage.canonical_sha256(), + evidence_digest=EVIDENCE_SHA, + resolved_policy_digest=policy.canonical_sha256(), + decision_scope=DEFAULT_SCOPE, + ) + return build_human_review_receipt( + decision="approve_for_promotion", + decision_scope=DEFAULT_SCOPE, + subject=subject, + reviewer=build_reviewer( + principal_id=opaque_principal_id("reviewer@example.invalid"), + role="scientific_reviewer", + authority_assertion=DEFAULT_AUTHORITY, + ), + reviewed_at="2026-07-20T00:00:00Z", + expires_at="2026-10-18T00:00:00Z", + concerns=[], + waivers=[], + supersedes=None, + ) + + +def _valid_receipt_kwargs() -> dict[str, Any]: + candidate = _candidate() + lineage = _lineage() + policy = _policy(human_signoff_required=False) + subject = build_review_subject( + project_id=candidate["project_id"], + artifact_id=candidate["artifact_id"], + artifact_sha256=candidate["sha256"], + lineage_receipt_sha256=lineage.canonical_sha256(), + evidence_digest=EVIDENCE_SHA, + resolved_policy_digest=policy.canonical_sha256(), + decision_scope=DEFAULT_SCOPE, + ) + return { + "gate_status": "eligible", + "subject": subject, + "digests": { + "report_sha256": "9" * 64, + "lineage_receipt_sha256": lineage.canonical_sha256(), + "publication_evidence_sha256": EVIDENCE_SHA, + "resolved_policy_sha256": policy.canonical_sha256(), + "review_receipt_sha256": None, + }, + "gates": [ + { + "code": code, + "outcome": "passed", + "evidence_ref": code.lower(), + "message": "passed", + } + for code in GATE_CODE_ORDER + ], + "requested_destination": "results/publication/Fig1.png", + } + + +def _base_kwargs(*, signoff_required: bool = True) -> dict[str, Any]: + candidate = _candidate() + lineage = _lineage() + policy = _policy(human_signoff_required=signoff_required) + return { + "workflow_intent": infer_workflow_intent(requested_intent="promotion"), + "candidate_artifact": candidate, + "runtime_manifest": _manifest(), + "durable_lineage_receipt": lineage, + "resolved_policy": policy, + "publication_readiness_report": _readiness(), + "review_policy": _review_policy(), + "now": "2026-07-21T00:00:00Z", + } + + +def test_valid_signoff_required_gate_is_eligible_and_receipt_bytes_are_stable() -> None: + candidate = _candidate() + lineage = _lineage() + policy = _policy(human_signoff_required=True) + review = _review(candidate, lineage, policy) + + report = evaluate_promotion_gate( + workflow_intent=infer_workflow_intent(requested_intent="promotion"), + candidate_artifact=candidate, + runtime_manifest=_manifest(), + durable_lineage_receipt=lineage, + resolved_policy=policy, + publication_readiness_report=_readiness(), + review_receipt=review, + review_policy=_review_policy(), + now="2026-07-21T00:00:00Z", + requested_destination="results/publication/Fig1.png", + ) + repeated = evaluate_promotion_gate( + workflow_intent=infer_workflow_intent(requested_intent="promotion"), + candidate_artifact=dict(reversed(list(candidate.items()))), + runtime_manifest=_manifest(), + durable_lineage_receipt=lineage.to_dict(), + resolved_policy=policy, + publication_readiness_report=dict(reversed(list(_readiness().items()))), + review_receipt=json.loads(json.dumps(review)), + review_policy=_review_policy(), + now="2026-07-21T00:00:00Z", + requested_destination="results/publication/Fig1.png", + ) + + assert report["gate_status"] == "eligible" + assert [gate["code"] for gate in report["gates"]] == list(GATE_CODE_ORDER) + assert canonical_promotion_gate_receipt_bytes(report["receipt_candidate"]) == ( + canonical_promotion_gate_receipt_bytes(repeated["receipt_candidate"]) + ) + validated = validate_promotion_gate_receipt(report["receipt_candidate"]) + payload_digest = promotion_gate_digest( + {key: validated[key] for key in validated if key not in {"receipt_id", "integrity"}} + ) + assert validated["receipt_id"] == f"promotion-gate:sha256:{payload_digest}" + assert hashlib.sha256(canonical_promotion_gate_receipt_bytes(validated)).hexdigest() + + +def test_missing_required_signoff_remains_needs_review_and_never_eligible() -> None: + report = evaluate_promotion_gate(**_base_kwargs(signoff_required=True)) + + signoff_gate = next(gate for gate in report["gates"] if gate["code"] == "HUMAN_REVIEW_SIGNOFF") + assert report["gate_status"] == "needs_review" + assert signoff_gate["outcome"] == "needs_review" + assert report["receipt_candidate"]["gate_status"] == "needs_review" + + +def test_invalid_review_receipt_blocks_with_verifier_reason() -> None: + candidate = _candidate() + lineage = _lineage() + policy = _policy(human_signoff_required=True) + mismatched_review = _review({**candidate, "sha256": "8" * 64}, lineage, policy) + + report = evaluate_promotion_gate( + workflow_intent=infer_workflow_intent(requested_intent="promotion"), + candidate_artifact=candidate, + runtime_manifest=_manifest(), + durable_lineage_receipt=lineage, + resolved_policy=policy, + publication_readiness_report=_readiness(), + review_receipt=mismatched_review, + review_policy=_review_policy(), + now="2026-07-21T00:00:00Z", + ) + + signoff_gate = next(gate for gate in report["gates"] if gate["code"] == "HUMAN_REVIEW_SIGNOFF") + assert report["gate_status"] == "blocked" + assert signoff_gate["outcome"] == "blocked" + assert "subject_mismatch" in signoff_gate["message"] + + +def test_missing_lineage_and_policy_evidence_block_in_declared_precedence_order() -> None: + report = evaluate_promotion_gate( + workflow_intent=infer_workflow_intent(requested_intent="promotion"), + candidate_artifact=_candidate(), + runtime_manifest=_manifest(), + durable_lineage_receipt=None, + resolved_policy=None, + publication_readiness_report=_readiness(), + now="2026-07-21T00:00:00Z", + ) + + failed_codes = [gate["code"] for gate in report["gates"] if gate["outcome"] == "blocked"] + assert report["gate_status"] == "blocked" + assert failed_codes == ["LINEAGE_RECEIPT_VALID", "POLICY_RESOLUTION_VALID"] + + +def test_automated_needs_revision_takes_precedence_over_missing_optional_signoff() -> None: + report = evaluate_promotion_gate( + **{ + **_base_kwargs(signoff_required=False), + "publication_readiness_report": _readiness("needs_revision"), + } + ) + + assert report["gate_status"] == "needs_revision" + readiness_gate = next(gate for gate in report["gates"] if gate["code"] == "PUBLICATION_READINESS_AUTOMATED") + assert readiness_gate["outcome"] == "needs_revision" + + +@pytest.mark.parametrize( + ("field", "replacement", "blocked_code"), + [ + ( + "workflow_intent", + infer_workflow_intent(requested_intent="promotion").to_dict(), + "WORKFLOW_PROMOTION_ALLOWED", + ), + ("resolved_policy", _policy(human_signoff_required=False).to_json(), "POLICY_RESOLUTION_VALID"), + ], +) +def test_forged_policy_or_workflow_mappings_fail_closed( + field: str, + replacement: dict[str, Any], + blocked_code: str, +) -> None: + report = evaluate_promotion_gate( + **{ + **_base_kwargs(signoff_required=False), + field: replacement, + } + ) + + gate = next(item for item in report["gates"] if item["code"] == blocked_code) + assert report["gate_status"] == "blocked" + assert gate["outcome"] == "blocked" + + +@pytest.mark.parametrize( + "claim_inventory", + [ + { + "schema_version": "figops_claim_inventory/1", + "status": "verified", + "promotion_eligible": True, + "manual_review_needed": False, + "errors": [], + "explicit_no_claims": True, + }, + { + "schema_version": "figops_claim_inventory/1", + "status": "verified", + "promotion_eligible": True, + "manual_review_needed": False, + "errors": [], + "claims": [], + "explicit_no_claims": False, + }, + ], +) +def test_incomplete_claim_inventory_blocks_existing_machine_eligibility(claim_inventory: dict[str, Any]) -> None: + manifest = _manifest() + manifest["claim_inventory"] = claim_inventory + + report = evaluate_promotion_gate( + **{ + **_base_kwargs(signoff_required=False), + "runtime_manifest": manifest, + } + ) + + runtime_gate = next(gate for gate in report["gates"] if gate["code"] == "RUNTIME_MANIFEST_ELIGIBLE") + assert report["gate_status"] == "blocked" + assert runtime_gate["outcome"] == "blocked" + + +@pytest.mark.parametrize( + "claim_inventory", + [ + { + "schema_version": "figops_claim_inventory/1", + "status": "verified", + "promotion_eligible": True, + "errors": [], + "claims": [{"claim_id": "claim-1"}], + }, + { + "schema_version": "figops_claim_inventory/1", + "status": "verified", + "promotion_eligible": True, + "manual_review_needed": False, + "claims": [{"claim_id": "claim-1"}], + }, + ], +) +def test_nonempty_claim_inventory_missing_review_or_errors_fields_blocks(claim_inventory: dict[str, Any]) -> None: + manifest = _manifest() + manifest["claim_inventory"] = claim_inventory + + report = evaluate_promotion_gate( + **{ + **_base_kwargs(signoff_required=False), + "runtime_manifest": manifest, + } + ) + + runtime_gate = next(gate for gate in report["gates"] if gate["code"] == "RUNTIME_MANIFEST_ELIGIBLE") + assert report["gate_status"] == "blocked" + assert runtime_gate["outcome"] == "blocked" + + +def test_receipt_status_cannot_be_forged_against_gate_outcome_precedence() -> None: + blocked_gate_kwargs = _valid_receipt_kwargs() + blocked_gate_kwargs["gates"][0] = { + **blocked_gate_kwargs["gates"][0], + "outcome": "blocked", + } + with pytest.raises(PromotionGateReceiptError, match="derived gate outcome precedence"): + build_promotion_gate_receipt(**blocked_gate_kwargs) + + false_blocked_kwargs = _valid_receipt_kwargs() + false_blocked_kwargs["gate_status"] = "blocked" + with pytest.raises(PromotionGateReceiptError, match="derived gate outcome precedence"): + build_promotion_gate_receipt(**false_blocked_kwargs) + + +def test_eligible_receipt_requires_bound_subject_and_required_digests() -> None: + no_subject = _valid_receipt_kwargs() + no_subject["subject"] = None + with pytest.raises(PromotionGateReceiptError, match="bound subject"): + build_promotion_gate_receipt(**no_subject) + + missing_digests = _valid_receipt_kwargs() + for field in ("lineage_receipt_sha256", "publication_evidence_sha256", "resolved_policy_sha256"): + forged = { + **missing_digests, + "digests": { + **missing_digests["digests"], + field: None, + }, + } + with pytest.raises(PromotionGateReceiptError, match=f"digests.{field}"): + build_promotion_gate_receipt(**forged) + + +@pytest.mark.parametrize( + ("digest_field", "expected_subject_field"), + [ + ("lineage_receipt_sha256", "lineage_receipt_sha256"), + ("publication_evidence_sha256", "evidence_digest"), + ("resolved_policy_sha256", "resolved_policy_digest"), + ], +) +def test_receipt_subject_digest_bindings_must_match_receipt_digests( + digest_field: str, + expected_subject_field: str, +) -> None: + forged = _valid_receipt_kwargs() + forged["digests"] = { + **forged["digests"], + digest_field: "0" * 64, + } + + with pytest.raises(PromotionGateReceiptError, match=f"subject.{expected_subject_field}"): + build_promotion_gate_receipt(**forged) + + +@pytest.mark.parametrize("destination", ["/tmp/x", "C:/secret", "../publication/Fig1.png", "runtime:job/Fig1.png"]) +def test_receipt_requested_destination_rejects_absolute_uri_and_traversal(destination: str) -> None: + with pytest.raises(PromotionGateReceiptError, match="requested_destination"): + build_promotion_gate_receipt(**{**_valid_receipt_kwargs(), "requested_destination": destination}) + + receipt = build_promotion_gate_receipt(**_valid_receipt_kwargs()) + receipt["requested_destination"] = destination + with pytest.raises(PromotionGateReceiptError, match="requested_destination"): + validate_promotion_gate_receipt(receipt) + + +def test_receipt_gate_codes_are_closed_and_gate_text_rejects_path_like_leaks() -> None: + unknown_code = _valid_receipt_kwargs() + unknown_code["gates"][0] = {**unknown_code["gates"][0], "code": "FUTURE_GATE"} + with pytest.raises(PromotionGateReceiptError, match="closed enum"): + build_promotion_gate_receipt(**unknown_code) + + leaky_message = _valid_receipt_kwargs() + leaky_message["gates"][0] = {**leaky_message["gates"][0], "message": "see C:/secret"} + with pytest.raises(PromotionGateReceiptError, match="path|URI"): + build_promotion_gate_receipt(**leaky_message) + + +def test_receipt_gate_array_must_use_canonical_precedence_order() -> None: + reversed_gates = _valid_receipt_kwargs() + reversed_gates["gates"] = list(reversed(reversed_gates["gates"])) + + with pytest.raises(PromotionGateReceiptError, match="canonical promotion gate order"): + build_promotion_gate_receipt(**reversed_gates) + + +def test_invalid_decision_scope_blocks_instead_of_eligible_without_subject() -> None: + report = evaluate_promotion_gate( + **{ + **_base_kwargs(signoff_required=False), + "decision_scope": "not_a_supported_scope", + } + ) + + review_gate = next(gate for gate in report["gates"] if gate["code"] == "HUMAN_REVIEW_SIGNOFF") + assert report["gate_status"] == "blocked" + assert report["subject"] is None + assert report["receipt_candidate"]["subject"] is None + assert review_gate["outcome"] == "blocked" + assert review_gate["evidence_ref"] == "review_subject" + + +@pytest.mark.parametrize( + ("override", "expected_message"), + [ + ({"review_policy": {"allow_local_attestation": True}}, "HumanReviewVerificationPolicy"), + ({"now": []}, "invalid"), + ({"receipt_index": {}}, "invalid"), + ], +) +def test_malformed_review_verification_inputs_fail_closed(override: dict[str, Any], expected_message: str) -> None: + candidate = _candidate() + lineage = _lineage() + policy = _policy(human_signoff_required=True) + report = evaluate_promotion_gate( + **{ + **_base_kwargs(signoff_required=True), + "review_receipt": _review(candidate, lineage, policy), + **override, + } + ) + + review_gate = next(gate for gate in report["gates"] if gate["code"] == "HUMAN_REVIEW_SIGNOFF") + assert report["gate_status"] == "blocked" + assert review_gate["outcome"] == "blocked" + assert expected_message in review_gate["message"] + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("project_id", "C:/secret"), + ("artifact_id", "/tmp/x"), + ("artifact_id", "runtime:job/Fig1.png"), + ], +) +def test_receipt_subject_rejects_path_like_or_non_opaque_ids(field: str, value: str) -> None: + candidate = _candidate() + lineage = _lineage() + policy = _policy(human_signoff_required=False) + subject = build_review_subject( + project_id=candidate["project_id"], + artifact_id=candidate["artifact_id"], + artifact_sha256=candidate["sha256"], + lineage_receipt_sha256=lineage.canonical_sha256(), + evidence_digest=EVIDENCE_SHA, + resolved_policy_digest=policy.canonical_sha256(), + decision_scope=DEFAULT_SCOPE, + ) + subject[field] = value + + with pytest.raises(PromotionGateReceiptError, match="opaque|path|URI|runtime"): + build_promotion_gate_receipt( + gate_status="eligible", + subject=subject, + digests={ + "report_sha256": "9" * 64, + "lineage_receipt_sha256": lineage.canonical_sha256(), + "publication_evidence_sha256": EVIDENCE_SHA, + "resolved_policy_sha256": policy.canonical_sha256(), + "review_receipt_sha256": None, + }, + gates=[ + { + "code": code, + "outcome": "passed", + "evidence_ref": code.lower(), + "message": "passed", + } + for code in GATE_CODE_ORDER + ], + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("project_id", "C:/secret"), + ("artifact_id", "/tmp/x"), + ("artifact_id", "result.figure:nothex"), + ], +) +def test_path_like_candidate_ids_fail_closed_before_receipt_subject_binding(field: str, value: str) -> None: + candidate = _candidate() + candidate[field] = value + report = evaluate_promotion_gate( + **{ + **_base_kwargs(signoff_required=False), + "candidate_artifact": candidate, + } + ) + + candidate_gate = next(gate for gate in report["gates"] if gate["code"] == "CANDIDATE_ARTIFACT_BOUND") + assert report["gate_status"] == "blocked" + assert candidate_gate["outcome"] == "blocked" + assert report["subject"] is None + assert report["receipt_candidate"]["subject"] is None diff --git a/tests/test_release_discipline.py b/tests/test_release_discipline.py index 40d12f1..1bb2c46 100644 --- a/tests/test_release_discipline.py +++ b/tests/test_release_discipline.py @@ -2,11 +2,35 @@ import tomllib from pathlib import Path -from scripts.release_discipline import stale_post_tag_release_blocker +from scripts.release_discipline import post_tag_release_blocker, stale_post_tag_release_blocker HUB_ROOT = Path(__file__).resolve().parent.parent SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") CHANGELOG_VERSION_RE = re.compile(r"^## \[(?P\d+\.\d+\.\d+)] - \d{4}-\d{2}-\d{2}$", re.MULTILINE) +POST_TAG_METADATA_ROW = "| post_tag_metadata | 1 | requires_release_decision | yes |" + + +def _assert_release_status_matches_post_tag_state(status: str, post_tag_blocker: str | None) -> None: + status_is_blocked = "- Technical release gate: blocked" in status + status_is_green = "- Technical release gate: ok" in status + + assert status_is_blocked != status_is_green + if status_is_green: + assert post_tag_blocker is None + assert "- Technical release gate: ok" in status + assert "- Repository technically eligible for public release: yes" in status + assert "- Repository release allowed: yes" in status + assert "post_tag_metadata" not in status + return + + assert "- Technical release gate: blocked" in status + assert "- Repository technically eligible for public release: no" in status + assert "- Repository release allowed: no" in status + assert "- Technical blockers:" in status + assert "- Technical blockers: 0" not in status + assert POST_TAG_METADATA_ROW in status + if post_tag_blocker is not None: + assert "Release metadata is stale" in post_tag_blocker def test_package_version_is_semver_and_matches_latest_changelog_entry(): @@ -43,6 +67,28 @@ def test_stale_post_tag_release_blocker_allows_bumped_package_and_changelog_vers assert blocker is None +def test_release_status_assertion_preserves_clean_gate_expectations(): + status = """ +- Repository technically eligible for public release: yes +- Repository release allowed: yes +- Technical release gate: ok +""" + + _assert_release_status_matches_post_tag_state(status, None) + + +def test_release_status_assertion_fails_closed_for_stale_post_tag_metadata(): + status = f""" +- Repository technically eligible for public release: no +- Repository release allowed: no +- Technical release gate: blocked +- Technical blockers: 1 +{POST_TAG_METADATA_ROW} +""" + + _assert_release_status_matches_post_tag_state(status, "Release metadata is stale: 3 commits after v0.20.0") + + def test_v020_release_requires_structure_p1_gate(): status = (HUB_ROOT / "docs" / "packaging" / "public-release-status.md").read_text( encoding="utf-8" @@ -58,11 +104,11 @@ def test_v020_release_requires_structure_p1_gate(): ).read_text(encoding="utf-8") # A green technical inventory remains necessary; owner-recorded evidence - # supplies release authority for this scoped version. - assert "- Technical release gate: ok" in status - assert "- Repository technically eligible for public release: yes" in status + # supplies release authority for this scoped version. Post-tag metadata + # drift must fail closed until the next release version is chosen. + _assert_release_status_matches_post_tag_state(status, post_tag_release_blocker(HUB_ROOT)) + assert "- Repository publication authorized: yes" in status - assert "- Repository release allowed: yes" in status assert "- Authorization evidence references: 1" in status assert "technical gate remains independent evidence" in status assert "Decision record:" in status diff --git a/tests/test_render_evidence.py b/tests/test_render_evidence.py index 176fc12..6e43e0d 100644 --- a/tests/test_render_evidence.py +++ b/tests/test_render_evidence.py @@ -2,6 +2,7 @@ import copy import hashlib +import json from pathlib import Path import matplotlib.pyplot as plt @@ -15,7 +16,9 @@ RULE_VERSION, ArtifactPolicyMeasurementError, measure_artifact_policy, + resolve_render_policy_context, resolve_render_policy_selection, + resolve_render_validation_policies, verify_artifact_policy_projection, ) from hub_core.evidence_contract import EvidenceContractError, validate_evidence_envelope @@ -188,6 +191,80 @@ def test_primary_output_hash_mismatch_still_fails_closed_for_vector_render(tmp_p assert raised.value.code == "PRIMARY_OUTPUT_HASH_CONFLICT" +def test_policy_context_preserves_neutral_v2_and_nature_compatibility_split() -> None: + v2 = resolve_render_policy_context({}, compatibility=False) + compatibility = resolve_render_policy_context({}, compatibility=True) + + assert v2["render_policy"]["id"] == "render-neutral" + assert v2["render_policy"]["source"] == "v2-default" + assert v2["validation_target"] is None + assert compatibility["render_policy"]["id"] == "render-nature" + assert compatibility["render_policy"]["source"] == "compatibility-default" + assert compatibility["validation_target"] is None + + inferred_target, inferred_policy = resolve_render_validation_policies({}, target_format="nature") + v2_target, v2_policy = resolve_render_validation_policies( + {"v2_policy_contract": True}, + target_format="nature", + ) + assert (inferred_target, inferred_policy["id"]) == ("nature", "render-nature") + assert (v2_target, v2_policy["id"]) == ("", "render-nature") + + +def test_explicit_render_policy_wins_over_compatibility_target_projection() -> None: + explicit = { + "id": "render-science", + "version": "1", + "source": "explicit-render-policy", + "parameters": {"style_policy": "science", "mutates_journal_aesthetics": True}, + } + context = resolve_render_policy_context( + {"resolved_render_policy": explicit}, + target_format="nature", + compatibility=True, + ) + + assert context["render_policy"] == explicit + assert context["validation_target"] == "nature" + assert context["policy_set"]["parameters"]["render_policy"]["value"] == "science" + assert context["policy_set"]["parameters"]["validation_target"]["value"] == "nature" + + +def test_policy_context_records_source_opt_out_provenance_and_canonical_digest() -> None: + layer = { + "source": "project", + "policy_id": "project-research-ops", + "version": "1", + "parameters": {"require_figure_traceability": {"opt_out": True}}, + } + + left = resolve_render_policy_context({}, compatibility=False, policy_layers=[layer]) + reordered_layer = { + "parameters": layer["parameters"], + "version": layer["version"], + "policy_id": layer["policy_id"], + "source": layer["source"], + } + right = resolve_render_policy_context({}, compatibility=False, policy_layers=[reordered_layer]) + traceability = left["policy_set"]["parameters"]["require_figure_traceability"] + canonical_digest = hashlib.sha256( + json.dumps( + left["policy_set"], + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + ).hexdigest() + + assert left["source"] == "v2-default" + assert traceability["source"] == "explicit_project_opt_out" + assert traceability["opt_out_requested"] is True + assert traceability["opt_out_accepted"] is True + assert left["policy_set_sha256"] == canonical_digest + assert left["policy_set_sha256"] == right["policy_set_sha256"] + + def test_validator_measures_artifact_without_render_mutation(tmp_path: Path) -> None: job_root = tmp_path / "job" primary = job_root / "results" / "figure.png" @@ -217,6 +294,121 @@ def test_validator_measures_artifact_without_render_mutation(tmp_path: Path) -> assert evidence["policy_projections"][0]["status"] == "blocked" +def test_build_render_evidence_consumes_policy_context_with_old_policy_fields(tmp_path: Path) -> None: + job_root = tmp_path / "job" + primary = job_root / "results" / "figure.png" + primary.parent.mkdir(parents=True) + Image.new("RGB", (120, 80), "navy").save(primary, format="PNG", dpi=(300, 300)) + previews = _build_preview_artifacts( + job_root=job_root, + output_path=primary, + figures=[{"path": str(primary)}], + ) + manifest = _manifest("context-neutral-nature-audit", previews, _sha256(primary)) + context = resolve_render_policy_context({"validation_target": "nature"}, compatibility=False) + + evidence = build_render_evidence( + manifest, + job_root=job_root, + producer_kind="test-neutral-render", + producer_version="1", + policy_context=context, + ) + + assert evidence["resolved_policy"]["id"] == "journal-nature" + assert evidence["resolved_policy"]["parameters"]["render_policy"] == "render-neutral" + assert evidence["resolved_policy"]["parameters"]["validation_target"] == "nature" + assert evidence["policy_projections"][0]["id"] == "journal-nature" + assert evidence["policy_context"] == { + "schema_version": context["schema_version"], + "source": context["source"], + "policy_set_sha256": context["policy_set_sha256"], + "render_policy": context["render_policy"], + "validation_target": context["validation_target"], + } + assert evidence["policy_context"]["policy_set_sha256"] == context["policy_set_sha256"] + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("policy_set_sha256", None, "policy_context digest is malformed"), + ("policy_set_sha256", "forged", "policy_context digest is malformed"), + ("schema_version", "figops-render-policy-context/0", "policy_context schema is malformed"), + ("render_policy", None, "policy_context render_policy is malformed"), + ], +) +def test_build_render_evidence_rejects_malformed_policy_context( + tmp_path: Path, + field: str, + value: object, + message: str, +) -> None: + job_root = tmp_path / "job" + primary = job_root / "results" / "figure.png" + primary.parent.mkdir(parents=True) + Image.new("RGB", (120, 80), "navy").save(primary, format="PNG", dpi=(300, 300)) + previews = _build_preview_artifacts( + job_root=job_root, + output_path=primary, + figures=[{"path": str(primary)}], + ) + manifest = _manifest("malformed-policy-context", previews, _sha256(primary)) + context = resolve_render_policy_context({"validation_target": "nature"}, compatibility=False) + if value is None: + context.pop(field) + else: + context[field] = value + + with pytest.raises(ValueError, match=message): + build_render_evidence( + manifest, + job_root=job_root, + producer_kind="test-neutral-render", + producer_version="1", + policy_context=context, + ) + + +def test_legacy_render_policy_argument_conflicts_keep_public_errors(tmp_path: Path) -> None: + job_root = tmp_path / "job" + primary = _pdf(job_root / "results" / "figure.pdf") + previews = _build_preview_artifacts( + job_root=job_root, + output_path=primary, + figures=[{"path": str(primary)}], + ) + manifest = _manifest("legacy-conflicts", previews, _sha256(primary)) + legacy = { + "id": "legacy-policy", + "version": "1", + "source": "legacy-test", + "parameters": {}, + } + + with pytest.raises( + ValueError, + match="validation_target cannot be combined with the legacy resolved_policy argument", + ): + build_render_evidence( + manifest, + job_root=job_root, + producer_kind="test-render", + producer_version="1", + resolved_policy=legacy, + validation_target="nature", + ) + with pytest.raises(ValueError, match="render_policy conflicts with the legacy resolved_policy argument"): + build_render_evidence( + manifest, + job_root=job_root, + producer_kind="test-render", + producer_version="1", + resolved_policy=legacy, + render_policy=resolve_render_policy_selection("neutral"), + ) + + def test_policy_projection_binds_artifact_rule_and_measurement_versions(tmp_path: Path) -> None: job_root = tmp_path / "job" primary = job_root / "results" / "figure.png" diff --git a/tests/test_render_project_policy_integration.py b/tests/test_render_project_policy_integration.py index b1a2f0f..7a05c95 100644 --- a/tests/test_render_project_policy_integration.py +++ b/tests/test_render_project_policy_integration.py @@ -16,6 +16,7 @@ def _write_project( geometry_mode: str = "direct", validation_target: str | None = None, declare_claim: bool = True, + workflow_intent: str | None = None, ) -> Path: project = root / "project" (project / "hub_scripts").mkdir(parents=True) @@ -64,9 +65,11 @@ def _write_project( inventory_line = " claim_inventory: results/evidence/Fig1.claims.json\n" if claim_inventory else "" claim_line = " claim: Fixture render completes.\n" if declare_claim else "" validation_line = f" validation_target: {validation_target}\n" if validation_target else "" + workflow_block = f"workflow:\n intent: {workflow_intent}\n" if workflow_intent else "" (project / "project_config.yaml").write_text( "project:\n" " name: Project policy integration\n" + f"{workflow_block}" "visual_style:\n" " target_format: nature\n" " profile: baseline\n" @@ -117,6 +120,7 @@ def _render( dpi: int = 300, geometry_mode: str = "direct", declare_claim: bool = True, + workflow_intent: str | None = None, ): research_root = tmp_path / "research" project = _write_project( @@ -126,6 +130,7 @@ def _render( geometry_mode=geometry_mode, validation_target=validation_target, declare_claim=declare_claim, + workflow_intent=workflow_intent, ) runtime_root = tmp_path / "runtime" server = FigOpsMCPServer( @@ -190,6 +195,66 @@ def test_publication_projection_is_persisted_and_unverified_claims_block_promoti assert manifest["manual_review_needed"] is True +def test_project_render_manifest_binds_canonical_policy_context_digest(tmp_path: Path) -> None: + response, manifest = _render( + tmp_path, + job_id="project-policy-context", + claim_inventory=False, + validation_target="nature", + ) + + assert response["status"] == "warning", (response, manifest) + assert len(manifest["policy_context"]["policy_set_sha256"]) == 64 + assert manifest["policy_context"]["render_policy"]["id"] == "render-neutral" + assert manifest["policy_context"]["validation_target"] == "nature" + assert manifest["evidence"]["resolved_policy"]["parameters"]["validation_target"] == "nature" + + +def test_policy_context_contradiction_fails_closed_without_promotion( + tmp_path: Path, + monkeypatch: Any, +) -> None: + research_root = tmp_path / "research" + project = _write_project(research_root, claim_inventory=False, validation_target="nature") + runtime_root = tmp_path / "runtime" + server = FigOpsMCPServer( + research_root=research_root, + runtime_root=runtime_root, + write_tools_enabled=True, + ) + real_build_render_evidence = build_render_evidence + + def contradict_policy_context(*args: Any, **kwargs: Any) -> dict[str, Any]: + kwargs["validation_target"] = "acs" + return real_build_render_evidence(*args, **kwargs) + + def fail_promotion(*_args: Any, **_kwargs: Any) -> None: + raise AssertionError("contradicted policy context must not reach durable promotion") + + monkeypatch.setattr( + "hub_core.mcp.tools.render_project.build_render_evidence", + contradict_policy_context, + ) + monkeypatch.setattr( + "hub_core.mcp.tools.render_project.promote_eligible_project_result", + fail_promotion, + ) + + response = server.call_tool( + "figops.render_project_script", + { + "project_path": str(project), + "figure_id": "Fig1", + "job_id": "project-policy-context-contradiction", + "validation_target": "nature", + }, + )["structuredContent"] + + assert response["status"] == "error" + assert response["failure_stage"] == "PLOT" + assert any("validation_target conflicts with policy_context" in line for line in response["errors"]) + + def test_publication_missing_projection_blocks_promotion_with_verified_claims( tmp_path: Path, monkeypatch: Any, @@ -208,6 +273,7 @@ def test_publication_missing_projection_blocks_promotion_with_verified_claims( write_tools_enabled=True, ) def without_projection(*args: Any, **kwargs: Any) -> dict[str, Any]: + kwargs["policy_context"] = None kwargs["validation_target"] = None return build_render_evidence(*args, **kwargs) @@ -261,6 +327,39 @@ def test_passing_measured_journal_minima_make_verified_claims_promotion_eligible assert len(list((project / "results" / "evidence").glob("*.receipt.json"))) == 1 +def test_exploration_workflow_blocks_promotion_and_does_not_invoke_durable_promotion( + tmp_path: Path, + monkeypatch: Any, +) -> None: + def fail_promotion(*_args: Any, **_kwargs: Any) -> None: + raise AssertionError("exploration workflow must not invoke durable promotion") + + monkeypatch.setattr( + "hub_core.mcp.tools.render_project.promote_eligible_project_result", + fail_promotion, + ) + response, manifest = _render( + tmp_path, + job_id="project-exploration-non-promotable", + claim_inventory=True, + validation_target="nature", + dpi=600, + geometry_mode="compliant", + declare_claim=False, + workflow_intent="exploration", + ) + + assert response["status"] == "warning", (response, manifest) + assert manifest["workflow_intent"]["intent"] == "exploration" + assert manifest["workflow_intent"]["execution_allowed"] is False + assert manifest["workflow_intent"]["fail_closed"] is True + assert manifest["claim_inventory"]["promotion_eligible"] is True + assert manifest["promotion_eligible"] is False + project = tmp_path / "research" / "project" + assert (project / "results" / "figures" / "Fig1.png").exists() is False + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + def test_direct_tiny_font_and_line_without_geometry_sidecar_blocks_promotion( tmp_path: Path, ) -> None: diff --git a/tests/test_result_promotion_integration.py b/tests/test_result_promotion_integration.py index 23570be..99e9124 100644 --- a/tests/test_result_promotion_integration.py +++ b/tests/test_result_promotion_integration.py @@ -2,8 +2,10 @@ import hashlib import json +import os import shutil from pathlib import Path +from unittest.mock import patch import pytest from PIL import Image @@ -11,9 +13,21 @@ from hub_core.artifact_policy_measurement import measure_artifact_policy from hub_core.claim_inventory import evaluate_project_claim_inventory from hub_core.durable_promotion import verify_promoted_result +from hub_core.human_review_receipt import ( + calculate_subject_digest, + opaque_figure_artifact_id, + opaque_project_id, +) from hub_core.mcp import FigOpsMCPServer +from hub_core.promotion_gate_receipt import ( + GATE_CODE_ORDER, + build_promotion_gate_receipt, + canonical_promotion_gate_receipt_bytes, +) from hub_core.result_promotion import ResultPromotionError, promote_eligible_project_result +DEFAULT_SCOPE = "figure_scientific_and_communication" + def _manifest(*, job_id: str, output_sha256: str, eligible: bool = True) -> dict[str, object]: digest = "1" * 64 @@ -128,6 +142,94 @@ def _bind_policy(manifest: dict[str, object], artifact: Path, digest: str) -> No evidence["policy_projections"] = [measured["policy_projection"]] +def _eligible_promotion_fixture(tmp_path: Path, *, job_id: str) -> dict[str, object]: + project = tmp_path / "project" + runtime = tmp_path / "runtime" + runtime_artifact = runtime / "mcp_project_jobs" / job_id / "project" / "results" / "figures" / "Fig1.png" + output_sha256 = _write_compliant_png(runtime_artifact) + snapshot_root = runtime_artifact.parents[2] + selected = _write_no_claim_snapshot(snapshot_root) + manifest = _manifest(job_id=job_id, output_sha256=output_sha256) + manifest["claim_inventory"] = evaluate_project_claim_inventory(snapshot_root, selected) + _bind_policy(manifest, runtime_artifact, output_sha256) + manifest_path = runtime_artifact.parents[3] / "manifest.json" + manifest_path.write_text(json.dumps(manifest, sort_keys=True), encoding="utf-8") + (project / "results" / "figures").mkdir(parents=True) + (project / "results" / "evidence").mkdir(parents=True) + return { + "project_root": project, + "config": { + "project": {"name": "Promotion integration"}, + "visual_style": {"validation_target": "nature"}, + }, + "runtime_root": runtime, + "runtime_artifact": runtime_artifact, + "output_relpath": "results/figures/Fig1.png", + "manifest": manifest, + "manifest_path": manifest_path, + "figure_id": "Fig1", + "selected_figure": selected, + "output_sha256": output_sha256, + } + + +def _gate_receipt( + *, + gate_status: str, + artifact_sha256: str = "1" * 64, + lineage_sha256: str = "2" * 64, + project_id: str | None = None, + artifact_id: str | None = None, + subject_digest: str | None = None, + decision_scope: str = DEFAULT_SCOPE, +) -> dict[str, object]: + if gate_status == "eligible": + subject_payload: dict[str, object] = { + "project_id": project_id or opaque_project_id("Promotion integration"), + "artifact_id": artifact_id or opaque_figure_artifact_id("Fig1"), + "artifact_sha256": artifact_sha256, + "lineage_receipt_sha256": lineage_sha256, + "evidence_digest": "3" * 64, + "resolved_policy_digest": "4" * 64, + } + subject: dict[str, object] | None = { + **subject_payload, + "subject_digest": subject_digest or calculate_subject_digest(subject_payload, decision_scope), + } + digests = { + "report_sha256": "6" * 64, + "lineage_receipt_sha256": lineage_sha256, + "publication_evidence_sha256": "3" * 64, + "resolved_policy_sha256": "4" * 64, + "review_receipt_sha256": None, + } + outcomes = ["passed"] * len(GATE_CODE_ORDER) + else: + subject = None + digests = { + "report_sha256": None, + "lineage_receipt_sha256": None, + "publication_evidence_sha256": None, + "resolved_policy_sha256": None, + "review_receipt_sha256": None, + } + outcomes = ["blocked", *(["passed"] * (len(GATE_CODE_ORDER) - 1))] + return build_promotion_gate_receipt( + gate_status=gate_status, + subject=subject, + digests=digests, + gates=[ + { + "code": code, + "outcome": outcome, + "evidence_ref": code.lower(), + "message": "test", + } + for code, outcome in zip(GATE_CODE_ORDER, outcomes) + ], + ) + + def test_eligible_runtime_result_is_promoted_with_runtime_independent_receipt(tmp_path: Path) -> None: project = tmp_path / "project" runtime = tmp_path / "runtime" @@ -184,6 +286,211 @@ def test_eligible_runtime_result_is_promoted_with_runtime_independent_receipt(tm } +def test_supplied_missing_promotion_gate_fails_closed_without_mutation(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-missing") + kwargs.pop("output_sha256") + kwargs["promotion_gate_receipt"] = {} + + with pytest.raises(ResultPromotionError, match="promotion gate receipt is invalid"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + assert not (project / "results" / "figures" / "Fig1.png").exists() + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + +def test_blocked_promotion_gate_report_fails_closed_without_mutation(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-blocked") + kwargs.pop("output_sha256") + kwargs["promotion_gate_receipt"] = { + "gate_status": "blocked", + "receipt_candidate": _gate_receipt(gate_status="blocked"), + } + + with pytest.raises(ResultPromotionError, match="promotion gate report is not eligible"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + assert not (project / "results" / "figures" / "Fig1.png").exists() + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + +def test_promotion_gate_subject_artifact_hash_must_match_primary(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-artifact-mismatch") + kwargs.pop("output_sha256") + kwargs["promotion_gate_receipt"] = _gate_receipt( + gate_status="eligible", + artifact_sha256="f" * 64, + ) + + with pytest.raises(ResultPromotionError, match="verified primary artifact"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + assert not (project / "results" / "figures" / "Fig1.png").exists() + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + +def test_promotion_gate_subject_project_id_must_match_project_name(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-project-mismatch") + output_sha256 = kwargs.pop("output_sha256") + assert isinstance(output_sha256, str) + kwargs["promotion_gate_receipt"] = _gate_receipt( + gate_status="eligible", + artifact_sha256=output_sha256, + project_id=opaque_project_id("different project"), + ) + + with pytest.raises(ResultPromotionError, match="trusted project identity"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + assert not (project / "results" / "figures" / "Fig1.png").exists() + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + +def test_promotion_gate_subject_artifact_id_must_match_figure_id(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-figure-mismatch") + output_sha256 = kwargs.pop("output_sha256") + assert isinstance(output_sha256, str) + kwargs["promotion_gate_receipt"] = _gate_receipt( + gate_status="eligible", + artifact_sha256=output_sha256, + artifact_id=opaque_figure_artifact_id("DifferentFigure"), + ) + + with pytest.raises(ResultPromotionError, match="selected figure identity"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + assert not (project / "results" / "figures" / "Fig1.png").exists() + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + +def test_promotion_gate_subject_digest_must_match_subject_fields(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-subject-digest") + output_sha256 = kwargs.pop("output_sha256") + assert isinstance(output_sha256, str) + kwargs["promotion_gate_receipt"] = _gate_receipt( + gate_status="eligible", + artifact_sha256=output_sha256, + subject_digest="f" * 64, + ) + + with pytest.raises(ResultPromotionError, match="subject digest"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + assert not (project / "results" / "figures" / "Fig1.png").exists() + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + +def test_promotion_gate_subject_lineage_must_match_constructed_receipt(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-lineage-mismatch") + output_sha256 = kwargs.pop("output_sha256") + assert isinstance(output_sha256, str) + kwargs["promotion_gate_receipt"] = _gate_receipt( + gate_status="eligible", + artifact_sha256=output_sha256, + lineage_sha256="e" * 64, + ) + + with pytest.raises(ResultPromotionError, match="durable lineage receipt"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + assert not (project / "results" / "figures" / "Fig1.png").exists() + assert list((project / "results" / "evidence").glob("*.receipt.json")) == [] + + +@pytest.mark.parametrize("decision_scope", [DEFAULT_SCOPE, "figure_visual_communication"]) +def test_eligible_promotion_gate_admits_after_lineage_binding(tmp_path: Path, decision_scope: str) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-eligible") + output_sha256 = kwargs.pop("output_sha256") + assert isinstance(output_sha256, str) + + # The first legacy promotion gives this fixture's deterministic receipt + # binding; remove only its published bytes before exercising the gated path. + initial = promote_eligible_project_result(**kwargs) + assert initial is not None + artifact, receipt_artifact = initial + lineage = verify_promoted_result( + artifact.path, + receipt_artifact.path, + durable_root=kwargs["project_root"] / "results", + forbidden_roots=(kwargs["runtime_root"],), + ) + artifact.path.unlink() + receipt_artifact.path.unlink() + kwargs["promotion_gate_receipt"] = _gate_receipt( + gate_status="eligible", + artifact_sha256=output_sha256, + lineage_sha256=lineage.canonical_sha256(), + decision_scope=decision_scope, + ) + kwargs["promotion_gate_decision_scope"] = decision_scope + + promoted = promote_eligible_project_result(**kwargs) + + assert promoted is not None + + +def test_promotion_failure_rolls_back_frozen_gate_receipt(tmp_path: Path) -> None: + kwargs = _eligible_promotion_fixture(tmp_path, job_id="job-gate-promotion-failure") + output_sha256 = kwargs.pop("output_sha256") + assert isinstance(output_sha256, str) + + initial = promote_eligible_project_result(**kwargs) + assert initial is not None + artifact, receipt_artifact = initial + lineage = verify_promoted_result( + artifact.path, + receipt_artifact.path, + durable_root=kwargs["project_root"] / "results", + forbidden_roots=(kwargs["runtime_root"],), + ) + artifact.path.unlink() + receipt_artifact.path.unlink() + kwargs["promotion_gate_receipt"] = _gate_receipt( + gate_status="eligible", + artifact_sha256=output_sha256, + lineage_sha256=lineage.canonical_sha256(), + ) + + with patch( + "hub_core.result_promotion.promote_result_with_receipt", + side_effect=RuntimeError("simulated promotion failure"), + ): + if os.name == "nt": + with pytest.raises(RuntimeError, match="simulated promotion failure"): + promote_eligible_project_result(**kwargs) + else: + with pytest.raises(ResultPromotionError, match="rollback was withheld"): + promote_eligible_project_result(**kwargs) + + project = kwargs["project_root"] + assert isinstance(project, Path) + gate_destinations = list((project / "results" / "evidence").glob("*.promotion-gate.json")) + if os.name == "nt": + assert gate_destinations == [] + else: + # POSIX cannot delete by verified file identity, so the gate receipt + # remains for manual review rather than risking a pathname race. + assert len(gate_destinations) == 1 + gate_destination = gate_destinations[0] + assert gate_destination.read_bytes() == canonical_promotion_gate_receipt_bytes( + kwargs["promotion_gate_receipt"] + ) + assert not (project / "results" / "figures" / "Fig1.png").exists() + + def test_unverified_or_review_required_runtime_result_is_never_promoted(tmp_path: Path) -> None: project = tmp_path / "project" runtime = tmp_path / "runtime" diff --git a/tests/test_review_recording.py b/tests/test_review_recording.py new file mode 100644 index 0000000..f194e2c --- /dev/null +++ b/tests/test_review_recording.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from hub_core.human_review_receipt import canonical_human_review_receipt_bytes +from hub_core.review_recording import ( + ReviewRecordExistsError, + ReviewRecordingAuthorizationError, + ReviewRecordingError, + record_human_review_receipt, +) +from tests.human_review_receipt_helpers import receipt + + +def _record(tmp_path: Path, *, value: object, relative_path: str = "human/review.json", **kwargs: object): + return record_human_review_receipt( + value, + evidence_root=tmp_path / "evidence", + relative_path=relative_path, + write_authorized=True, + **kwargs, + ) + + +def test_records_exact_canonical_bytes_and_returns_runtime_independent_result(tmp_path: Path) -> None: + review = receipt() + result = _record(tmp_path, value=review) + destination = tmp_path / "evidence" / result.relative_path + + assert destination.read_bytes() == canonical_human_review_receipt_bytes(review) + assert result.record_relative_path == "human/review.json" + assert result.receipt_id == review["receipt_id"] + assert result.canonical_sha256 == review["integrity"]["canonical_sha256"] + assert result.size_bytes == destination.stat().st_size + assert str(tmp_path) not in repr(result) + + +def test_accepts_only_exact_canonical_receipt_bytes(tmp_path: Path) -> None: + review = receipt() + canonical = canonical_human_review_receipt_bytes(review) + result = _record(tmp_path, value=canonical, relative_path="bytes.json") + assert (tmp_path / "evidence" / result.relative_path).read_bytes() == canonical + + with pytest.raises(ReviewRecordingError, match="canonical"): + _record(tmp_path / "noncanonical", value=b" " + canonical, relative_path="bytes.json") + + +def test_write_authorized_false_fails_before_validation_or_filesystem_touch(tmp_path: Path) -> None: + root = tmp_path / "evidence" + with pytest.raises(ReviewRecordingAuthorizationError, match="disabled"): + record_human_review_receipt( + object(), # type: ignore[arg-type] + evidence_root=root, + relative_path="review.json", + write_authorized=False, + ) + assert not root.exists() + + +@pytest.mark.parametrize( + "relative_path", + ["/outside.json", "../outside.json", "nested/../../outside.json", r"nested\\review.json", "C:/outside.json"], +) +def test_absolute_and_traversal_destinations_are_rejected(tmp_path: Path, relative_path: str) -> None: + with pytest.raises(ReviewRecordingError, match="canonical|relative"): + _record(tmp_path, value=receipt(), relative_path=relative_path) + assert not (tmp_path / "outside.json").exists() + + +def test_symlinked_evidence_root_is_rejected(tmp_path: Path) -> None: + outside = tmp_path / "outside" + outside.mkdir() + root = tmp_path / "evidence" + try: + root.symlink_to(outside, target_is_directory=True) + except OSError: + pytest.skip("host cannot create directory symlinks") + + with pytest.raises(ReviewRecordingError, match="symlink|reparse"): + _record(tmp_path, value=receipt()) + assert not (outside / "human" / "review.json").exists() + + +def test_symlinked_destination_parent_is_rejected(tmp_path: Path) -> None: + root = tmp_path / "evidence" + root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + link = root / "link" + try: + link.symlink_to(outside, target_is_directory=True) + except OSError: + pytest.skip("host cannot create directory symlinks") + + with pytest.raises(ReviewRecordingError, match="symlink|reparse|unsafe"): + _record(tmp_path, value=receipt(), relative_path="link/review.json") + assert not (outside / "review.json").exists() + + +def test_witness_runtime_failure_is_public_review_recording_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import hub_core.review_recording as recording + + def unsafe_witness(*args: object, **kwargs: object) -> object: + raise RuntimeError("Structure directory changed during apply.") + + monkeypatch.setattr(recording, "capture_directory_witness", unsafe_witness) + + with pytest.raises(ReviewRecordingError, match="Structure directory changed during apply"): + _record(tmp_path, value=receipt()) + assert not (tmp_path / "evidence" / "human" / "review.json").exists() + + +def test_lease_runtime_failure_is_public_review_recording_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import hub_core.review_recording as recording + + def unsafe_lease(*args: object, **kwargs: object) -> object: + raise RuntimeError("Could not lease structure directory.") + + monkeypatch.setattr(recording, "lease_directory_witness", unsafe_lease) + + with pytest.raises(ReviewRecordingError, match="Could not lease structure directory"): + _record(tmp_path, value=receipt()) + assert not (tmp_path / "evidence" / "human" / "review.json").exists() + + +def test_existing_destination_is_never_overwritten(tmp_path: Path) -> None: + root = tmp_path / "evidence" + root.mkdir() + destination = root / "review.json" + destination.write_bytes(b"winner") + + with pytest.raises(ReviewRecordExistsError): + _record(tmp_path, value=receipt(), relative_path="review.json") + assert destination.read_bytes() == b"winner" + + +def test_no_clobber_race_preserves_competitor(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import hub_core.review_recording as recording + + def competing_move(source: Path, destination: Path) -> None: + destination.write_bytes(b"competitor") + raise FileExistsError(str(destination)) + + monkeypatch.setattr(recording, "atomic_no_clobber_move", competing_move) + with pytest.raises(ReviewRecordExistsError): + _record(tmp_path, value=receipt(), relative_path="race.json") + + destination = tmp_path / "evidence" / "race.json" + assert destination.read_bytes() == b"competitor" + assert not list((tmp_path / "evidence").rglob("*.tmp")) + + +def test_malformed_receipt_never_creates_evidence_root(tmp_path: Path) -> None: + malformed = receipt() + malformed["integrity"] = {"canonical_sha256": "0" * 64} + with pytest.raises(ReviewRecordingError, match="malformed"): + _record(tmp_path, value=malformed) + assert not (tmp_path / "evidence").exists() + + +def test_record_bytes_remain_after_runtime_tree_deletion(tmp_path: Path) -> None: + runtime = tmp_path / "runtime" + runtime.mkdir() + runtime_file = runtime / "manifest.json" + runtime_file.write_text("runtime", encoding="utf-8") + result = _record(tmp_path, value=receipt(), relative_path="review.json") + runtime_file.unlink() + runtime.rmdir() + payload = json.loads((tmp_path / "evidence" / result.relative_path).read_bytes()) + assert payload["receipt_id"] == result.receipt_id diff --git a/tests/test_structure_apply.py b/tests/test_structure_apply.py index d3ed0e0..21855f7 100644 --- a/tests/test_structure_apply.py +++ b/tests/test_structure_apply.py @@ -9,7 +9,7 @@ from hub_core.atomic_no_clobber import AtomicNoClobberUnavailable from hub_core.structure_apply import apply_structure_plan -from hub_core.structure_plan import build_structure_plan, confirmation_token +from hub_core.structure_plan import build_structure_plan, canonical_plan_digest, confirmation_token def _test_no_clobber_move(source: Path, destination: Path) -> None: @@ -42,6 +42,22 @@ def test_copy_apply_requires_matching_token_and_preserves_original(tmp_path: Pat assert (tmp_path / "raw" / "input.csv").read_bytes() == source.read_bytes() +@pytest.mark.parametrize("missing_field", ["hardcoded_unresolved_references", "unresolved_proposals"]) +def test_apply_rejects_v2_plan_missing_scanner_fields(tmp_path: Path, missing_field: str) -> None: + source = tmp_path / "legacy" / "input.csv" + source.parent.mkdir() + source.write_bytes(b"original") + plan = _plan(tmp_path) + del plan[missing_field] + plan["digest"] = canonical_plan_digest(plan) + + with pytest.raises(ValueError, match=rf"missing the required {missing_field} field"): + apply_structure_plan(plan, confirmation_token=confirmation_token(plan)) + + assert not (tmp_path / "raw" / "input.csv").exists() + assert source.read_bytes() == b"original" + + def test_apply_rejects_stale_source_collision_and_unresolved_reference(tmp_path: Path) -> None: source = tmp_path / "legacy" / "input.csv" source.parent.mkdir() @@ -67,6 +83,18 @@ def test_apply_rejects_stale_source_collision_and_unresolved_reference(tmp_path: with pytest.raises(RuntimeError, match="hard-coded"): apply_structure_plan(unresolved, confirmation_token=confirmation_token(unresolved)) + unresolved_proposal = build_structure_plan( + tmp_path, + [{"source": "legacy/input.csv", "destination": "raw/input.csv", "role": "raw"}], + unresolved_proposals=[{"source": "notes.md", "reason": "ambiguous"}], + ) + assert unresolved_proposal["unresolved_proposals"] == [{"source": "notes.md", "reason": "ambiguous"}] + with pytest.raises(RuntimeError, match="normalization proposals"): + apply_structure_plan( + unresolved_proposal, + confirmation_token=confirmation_token(unresolved_proposal), + ) + def test_apply_race_never_clobbers_competing_destination(tmp_path: Path, monkeypatch) -> None: source = tmp_path / "legacy" / "input.csv" diff --git a/tests/test_structure_audit_report.py b/tests/test_structure_audit_report.py new file mode 100644 index 0000000..1f27254 --- /dev/null +++ b/tests/test_structure_audit_report.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import copy +import json +from pathlib import Path + +import pytest + +import hub_core.structure_audit_report as report_module + + +def _discovered(*items): + defaults = { + "project_id": "id", + "name": "Project", + "path": "project", + "config": "project_config.yaml", + "role": "module", + "status": "active", + "classification": "official", + "target_format": "nature", + "valid": True, + "errors": [], + } + return [{**defaults, **item} for item in items] + + +def test_build_report_audits_in_stable_path_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + (tmp_path / "a").mkdir() + (tmp_path / "b").mkdir() + projects = _discovered( + {"project_id": "b", "name": "B", "path": "b"}, + {"project_id": "a", "name": "A", "path": "a"}, + ) + monkeypatch.setattr(report_module, "discover_projects_with_status", lambda *a, **k: projects) + monkeypatch.setattr(report_module, "resolve_execution_project_path", lambda root, path: root / path) + monkeypatch.setattr(report_module, "load_config", lambda path: ({"project": {"name": path.name}}, "cfg", "hash")) + monkeypatch.setattr( + report_module, + "audit_project_structure", + lambda path, config: {"roles": {}, "graph": {}, "findings": [{"code": "x"}], "unknowns": []}, + ) + report = report_module.build_structure_audit_report(tmp_path) + assert [item["path"] for item in report["projects"]] == ["a", "b"] + assert report["summary"]["audited_count"] == 2 + assert report["summary"]["finding_count"] == 2 + + +def test_invalid_and_boundary_blocked_entries_are_retained(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + projects = _discovered( + {"project_id": "invalid", "path": "invalid", "valid": False, "errors": ["bad yaml"]}, + {"project_id": "alias", "path": "alias"}, + ) + monkeypatch.setattr(report_module, "discover_projects_with_status", lambda *a, **k: projects) + + def resolve(root, path): + if path == "alias": + raise report_module.ExecutionProjectPathError("outside root") + return root / path + + monkeypatch.setattr(report_module, "resolve_execution_project_path", resolve) + report = report_module.build_structure_audit_report(tmp_path) + statuses = {item["project_id"]: item["audit_status"] for item in report["projects"]} + assert statuses == {"alias": "boundary_blocked", "invalid": "invalid"} + assert report["summary"]["boundary_blocked_count"] == 1 + assert report["summary"]["invalid_count"] == 1 + + +def test_json_renderer_is_canonical_and_markdown_contains_summary(): + report = {"summary": {"project_count": 0}, "projects": [], "root": "/tmp/root", "max_depth": 4} + encoded = report_module.render_structure_audit_report(report, output_format="json") + assert json.loads(encoded) == report + assert encoded.endswith("\n") + markdown = report_module.render_structure_audit_report(report) + assert "# Project Structure Audit" in markdown + assert "project count" in markdown + + +def test_report_uses_documented_schema_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(report_module, "discover_projects_with_status", lambda *a, **k: []) + report = report_module.build_structure_audit_report(tmp_path) + assert report["schema_version"] == "figops.project-structure-audit-report.v1" + + +def test_renderer_rejects_unknown_format(): + with pytest.raises(ValueError): + report_module.render_structure_audit_report({}, output_format="xml") + + +def test_loader_exception_is_retained_as_audit_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + (tmp_path / "project").mkdir() + monkeypatch.setattr(report_module, "discover_projects_with_status", lambda *a, **k: _discovered({})) + monkeypatch.setattr(report_module, "resolve_execution_project_path", lambda root, path: root / path) + monkeypatch.setattr(report_module, "load_config", lambda path: (_ for _ in ()).throw(OSError("unreadable"))) + report = report_module.build_structure_audit_report(tmp_path) + assert report["projects"][0]["audit_status"] == "audit_error" + assert "unreadable" in report["projects"][0]["errors"] + + +def test_audit_proposals_are_stripped_from_read_only_report(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + (tmp_path / "project").mkdir() + monkeypatch.setattr(report_module, "discover_projects_with_status", lambda *a, **k: _discovered({})) + monkeypatch.setattr(report_module, "resolve_execution_project_path", lambda root, path: root / path) + monkeypatch.setattr(report_module, "load_config", lambda path: ({"project": {}}, None, None)) + monkeypatch.setattr( + report_module, + "audit_project_structure", + lambda path, config: {"findings": [], "unknowns": [], "proposed_changes": [{"copy": "x"}]}, + ) + report = report_module.build_structure_audit_report(tmp_path) + assert report["projects"][0]["proposed_changes"] == [] + assert report["projects"][0]["audit"]["proposed_changes"] == [] + assert report["proposed_changes"] == [] + + +def test_markdown_enumerates_unknown_paths_reasons_and_audit_errors(): + report = { + "root": "/tmp/root", + "max_depth": 4, + "summary": {"project_count": 2}, + "projects": [ + { + "project_id": "b", + "path": "b", + "audit": { + "findings": [], + "unknowns": [ + { + "path": "legacy/plot.R", + "candidate": { + "candidate_role": "unknown", + "reason": "ambiguous candidates: analysis_scripts, figure_scripts", + }, + } + ], + }, + "errors": ["configuration unreadable"], + }, + { + "project_id": "a", + "path": "a", + "audit": { + "findings": [], + "unknowns": [{"path": "misc.bin", "reason": "no semantic declaration"}], + }, + "errors": [{"path": "a/project_config.yaml", "reason": "invalid YAML"}], + }, + ], + } + + markdown = report_module.render_structure_audit_markdown(report) + + assert "## Unknowns" in markdown + assert "`legacy/plot.R`" in markdown + assert "ambiguous candidates: analysis_scripts, figure_scripts" in markdown + assert "`misc.bin`" in markdown + assert "no semantic declaration" in markdown + assert "## Audit Errors" in markdown + assert "`a/project_config.yaml`: invalid YAML" in markdown + assert "`b`: configuration unreadable" in markdown + + +def test_markdown_diagnostic_sections_are_deterministic_and_do_not_mutate_report(): + report = { + "root": "/tmp/root", + "max_depth": 4, + "summary": {"project_count": 2}, + "projects": [ + { + "project_id": "b", + "path": "b", + "audit": { + "findings": [], + "unknowns": [ + {"path": "z.txt", "reason": "z reason"}, + {"path": "a.txt", "reason": "a reason"}, + ], + }, + "errors": ["z error", "a error"], + }, + { + "project_id": "a", + "path": "a", + "audit": {"findings": [], "unknowns": [{"path": "m.txt", "reason": "m reason"}]}, + "errors": ["m error"], + }, + ], + } + before = copy.deepcopy(report) + + first = report_module.render_structure_audit_markdown(report) + second = report_module.render_structure_audit_markdown(report) + + assert first == second + assert report == before + assert first.index("### `a`") < first.index("### `b`") + assert first.index("`a.txt`") < first.index("`z.txt`") + assert first.index("`b`: a error") < first.index("`b`: z error") diff --git a/tests/test_structure_inventory.py b/tests/test_structure_inventory.py index 9b88475..ac26c32 100644 --- a/tests/test_structure_inventory.py +++ b/tests/test_structure_inventory.py @@ -60,3 +60,82 @@ def test_audit_is_diagnostic_only_and_flags_raw_output(tmp_path: Path) -> None: assert audit["proposed_changes"] == [] assert any(item["code"] == "raw_output" for item in audit["findings"]) + + +def test_configured_reference_precedes_extension_and_name_heuristics(tmp_path: Path) -> None: + config = _config() + config["pipeline"] = {"analysis": [{"script": "legacy/plot.py"}]} + script = tmp_path / "legacy" / "plot.py" + script.parent.mkdir(parents=True) + script.write_text("", encoding="utf-8") + + inventory = build_structure_inventory(tmp_path, config) + + unknown = next(item for item in inventory["unknowns"] if item["path"] == "legacy/plot.py") + assert unknown["candidate"] == { + "candidate_role": "analysis_scripts", + "confidence": 1.0, + "reason": "configured relationship declares the semantic role", + } + + +def test_conflicting_configured_relationships_remain_unknown_for_review(tmp_path: Path) -> None: + config = _config() + config["pipeline"] = {"analysis": [{"script": "legacy/plot.py"}]} + config["figures"] = [{"script": "legacy/plot.py"}] + script = tmp_path / "legacy" / "plot.py" + script.parent.mkdir(parents=True) + script.write_text("", encoding="utf-8") + + audit = audit_project_structure(tmp_path, config) + + unknown = next(item for item in audit["unknowns"] if item["path"] == "legacy/plot.py") + assert unknown["candidate"] == { + "candidate_role": "unknown", + "confidence": 1.0, + "reason": "ambiguous configured relationships: analysis_scripts, figure_scripts", + } + + +def test_inventory_ignores_metadata_scalars_and_dotted_module_names(tmp_path: Path) -> None: + config = _config() + config["schema_version"] = "1.1" + config["project"] = {"description": "Public-safe fixture for materials/polymer analysis."} + config["pipeline"] = { + "analysis": [ + { + "domain_helper": "materials_polymer.signal_smooth_baseline", + "inputs": ["raw/input.csv"], + "outputs": ["results/data/intermediate/clean.csv"], + } + ] + } + + inventory = build_structure_inventory(tmp_path, config) + ignored = { + "1.1", + "Public-safe fixture for materials/polymer analysis.", + "materials_polymer.signal_smooth_baseline", + } + graph_ids = {node["id"] for node in inventory["graph"]["nodes"]} + + assert graph_ids.isdisjoint(ignored) + assert all(item.get("path") not in ignored for item in inventory["findings"]) + + +def test_inventory_walks_explicit_file_references_without_suffixes(tmp_path: Path) -> None: + config = _config() + config["pipeline"] = { + "analysis": [ + { + "script": "analysis_runner", + "inputs": ["input_dataset"], + "outputs": ["derived_result"], + } + ] + } + + inventory = build_structure_inventory(tmp_path, config) + graph_ids = {node["id"] for node in inventory["graph"]["nodes"]} + + assert {"analysis_runner", "input_dataset", "derived_result"} <= graph_ids diff --git a/tests/test_workflow_intent.py b/tests/test_workflow_intent.py new file mode 100644 index 0000000..7f69d86 --- /dev/null +++ b/tests/test_workflow_intent.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import pytest + +from hub_core.workflow_intent import ( + DIRECT_CSV_TOOLS, + INTENT_EXECUTION, + INTENT_EXPLORATION, + INTENT_PROMOTION, + INTENT_REVIEW, + MCP_EXECUTION_TOOLS, + ORCHESTRATOR_EXECUTION_STEPS, + READ_ONLY_TOOLS, + READINESS_TOOLS, + SOURCE_DIRECT_CSV, + SOURCE_LEGACY, + SOURCE_MCP, + SOURCE_ORCHESTRATOR, + SOURCE_READ_ONLY, + SOURCE_READINESS, + WorkflowIntent, + WorkflowIntentError, + infer_workflow_intent, + parse_workflow_intent, + parse_workflow_source, +) + + +def test_strict_parser_normalizes_known_intent_and_source_only() -> None: + assert parse_workflow_intent(" Execution ") == INTENT_EXECUTION + assert parse_workflow_intent("review") == INTENT_REVIEW + assert parse_workflow_source("read-only") == SOURCE_READ_ONLY + + with pytest.raises(WorkflowIntentError, match="unknown workflow intent"): + parse_workflow_intent("run") + with pytest.raises(WorkflowIntentError, match="unknown workflow source"): + parse_workflow_source("notebook") + + +@pytest.mark.parametrize("step", sorted(ORCHESTRATOR_EXECUTION_STEPS)) +def test_orchestrator_active_execution_steps_infer_execution(step: str) -> None: + intent = infer_workflow_intent(active=True, step=step) + + assert intent.intent == INTENT_EXECUTION + assert intent.source == SOURCE_ORCHESTRATOR + assert intent.execution_allowed is True + assert intent.read_only is False + + +def test_inactive_or_unknown_orchestrator_step_fails_closed() -> None: + inactive = infer_workflow_intent(active=False, step="plot") + unknown = infer_workflow_intent(active=True, step="publish") + + for intent in (inactive, unknown): + assert intent.intent == INTENT_REVIEW + assert intent.source == SOURCE_ORCHESTRATOR + assert intent.execution_allowed is False + assert intent.fail_closed is True + assert intent.issues + + +@pytest.mark.parametrize("tool_name", sorted(MCP_EXECUTION_TOOLS)) +def test_mcp_project_render_surfaces_infer_execution(tool_name: str) -> None: + intent = infer_workflow_intent(active=True, tool_name=tool_name) + + assert intent.intent == INTENT_EXECUTION + assert intent.source == SOURCE_MCP + assert intent.execution_allowed is True + assert intent.promotable is False + + +@pytest.mark.parametrize("tool_name", sorted(DIRECT_CSV_TOOLS)) +def test_direct_csv_surfaces_infer_exploration(tool_name: str) -> None: + intent = infer_workflow_intent(active=True, tool_name=tool_name) + + assert intent.intent == INTENT_EXPLORATION + assert intent.source == SOURCE_DIRECT_CSV + assert intent.execution_allowed is False + assert intent.promotion_allowed is False + assert intent.read_only is False + + +@pytest.mark.parametrize("tool_name", sorted(READ_ONLY_TOOLS | READINESS_TOOLS)) +def test_read_only_and_readiness_surfaces_have_no_execution_intent(tool_name: str) -> None: + intent = infer_workflow_intent(active=True, tool_name=tool_name) + + assert intent.intent == INTENT_REVIEW + assert intent.source in {SOURCE_READ_ONLY, SOURCE_READINESS} + assert intent.execution_allowed is False + assert intent.read_only is True + + +def test_explicit_intent_does_not_override_known_surface_defaults() -> None: + csv_intent = infer_workflow_intent( + active=True, + tool_name="figops.render_csv_graph", + requested_intent=INTENT_EXECUTION, + ) + orchestrator_intent = infer_workflow_intent( + active=True, + step="all", + requested_intent=INTENT_EXPLORATION, + ) + readiness_intent = infer_workflow_intent( + active=True, + source="readiness", + requested_intent=INTENT_EXECUTION, + ) + + assert csv_intent.intent == INTENT_EXPLORATION + assert csv_intent.source == SOURCE_DIRECT_CSV + assert csv_intent.fail_closed is True + assert orchestrator_intent.intent == INTENT_EXPLORATION + assert orchestrator_intent.source == SOURCE_ORCHESTRATOR + assert orchestrator_intent.fail_closed is True + assert readiness_intent.intent == INTENT_REVIEW + assert readiness_intent.source == SOURCE_READINESS + assert readiness_intent.fail_closed is True + + +@pytest.mark.parametrize("requested_intent", (INTENT_REVIEW, INTENT_PROMOTION)) +@pytest.mark.parametrize("tool_name", sorted(MCP_EXECUTION_TOOLS)) +def test_active_project_render_non_execution_intent_conflicts_fail_closed( + tool_name: str, + requested_intent: str, +) -> None: + intent = infer_workflow_intent( + active=True, + tool_name=tool_name, + requested_intent=requested_intent, + ) + + assert intent.intent == requested_intent + assert intent.source == SOURCE_MCP + assert intent.execution_allowed is False + assert intent.promotion_allowed is False + assert intent.fail_closed is True + assert "conflicts" in intent.issues[0] + + +@pytest.mark.parametrize("requested_intent", (INTENT_REVIEW, INTENT_PROMOTION)) +def test_active_orchestrator_render_non_execution_intent_conflicts_fail_closed(requested_intent: str) -> None: + intent = infer_workflow_intent(active=True, step="plot", requested_intent=requested_intent) + + assert intent.intent == requested_intent + assert intent.source == SOURCE_ORCHESTRATOR + assert intent.execution_allowed is False + assert intent.promotion_allowed is False + assert intent.fail_closed is True + assert "conflicts" in intent.issues[0] + + +@pytest.mark.parametrize( + ("tool_name", "source", "requested_intent", "expected_intent", "expected_source"), + ( + ("figops.render_project_figure", SOURCE_READ_ONLY, INTENT_EXECUTION, INTENT_REVIEW, SOURCE_MCP), + ("figops.render_project_figure", SOURCE_READINESS, INTENT_EXECUTION, INTENT_REVIEW, SOURCE_MCP), + ("figops.render_csv_graph", None, INTENT_PROMOTION, INTENT_PROMOTION, SOURCE_DIRECT_CSV), + ("figops.evaluate_publication_readiness", None, INTENT_EXECUTION, INTENT_REVIEW, SOURCE_READINESS), + ), +) +def test_source_surface_and_intent_conflict_matrix_is_deterministic( + tool_name: str, + source: str | None, + requested_intent: str, + expected_intent: str, + expected_source: str, +) -> None: + intent = infer_workflow_intent( + active=True, + tool_name=tool_name, + source=source, + requested_intent=requested_intent, + ) + + assert intent.intent == expected_intent + assert intent.source == expected_source + assert intent.execution_allowed is False + assert intent.promotion_allowed is False + assert intent.fail_closed is True + assert "conflicts" in intent.issues[0] + + +def test_legacy_status_overrides_active_execution_surfaces() -> None: + for kwargs in ( + {"active": True, "step": "all"}, + {"active": True, "tool_name": "figops.render_project_figure"}, + {"active": True, "tool_name": "graphhub.render_project_figure"}, + ): + intent = infer_workflow_intent(**kwargs, project_status="legacy") + + assert intent.intent == INTENT_REVIEW + assert intent.source == SOURCE_LEGACY + assert intent.legacy is True + assert intent.execution_allowed is False + assert intent.promotable is False + assert intent.read_only is True + + +def test_unknown_intent_or_source_is_inspectable_and_fails_closed_for_execution() -> None: + intent = infer_workflow_intent(active=True, requested_intent="execution", source="notebook") + unknown_intent = infer_workflow_intent(active=True, requested_intent="run", source="mcp") + + assert intent.intent == INTENT_EXECUTION + assert intent.source is None + assert intent.execution_allowed is False + assert intent.fail_closed is True + assert intent.provenance["requested_source"] == "notebook" + assert intent.issues == ("unknown workflow source: 'notebook'",) + + assert unknown_intent.intent is None + assert unknown_intent.source == SOURCE_MCP + assert unknown_intent.execution_allowed is False + assert unknown_intent.fail_closed is True + assert unknown_intent.provenance["requested_intent"] == "run" + + +def test_explicit_promotion_is_closed_and_digest_is_deterministic() -> None: + left = infer_workflow_intent(requested_intent=INTENT_PROMOTION) + right = WorkflowIntent( + intent=INTENT_PROMOTION, + source="explicit", + provenance={"requested_intent": INTENT_PROMOTION}, + ) + + assert left.intent == INTENT_PROMOTION + assert left.promotion_allowed is True + assert left.digest == left.digest + assert len(left.digest) == 64 + assert right.digest == right.digest diff --git a/uv.lock b/uv.lock index 4cfa27d..a237bae 100644 --- a/uv.lock +++ b/uv.lock @@ -228,7 +228,7 @@ wheels = [ [[package]] name = "figops" -version = "0.20.0" +version = "0.20.1" source = { editable = "." } dependencies = [ { name = "adjusttext" }, @@ -271,7 +271,7 @@ requires-dist = [ { name = "pandas", specifier = ">=2.0,<3" }, { name = "pillow", specifier = ">=12.3.0,<13" }, { name = "pyarrow", marker = "extra == 'io'", specifier = ">=14.0" }, - { name = "pymdown-extensions", specifier = ">=10.21.3" }, + { name = "pymdown-extensions", specifier = ">=11.0.0" }, { name = "python-pptx", specifier = ">=1.0.2" }, { name = "pyyaml", specifier = ">=6.0,<7" }, { name = "rich", specifier = ">=13.0,<15" }, @@ -1024,15 +1024,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.21.3" +version = "11.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, ] [[package]]