From df930423e2312f27c426a3c641a28d2b645fee5f Mon Sep 17 00:00:00 2001 From: fraware Date: Sat, 16 May 2026 10:28:46 -0700 Subject: [PATCH 1/3] Add PCS v0.1 LabTrust import, portal rendering, and check tests Co-authored-by: Cursor --- .gitignore | 8 +- JUSTFILE | 15 ++ .../import_manifest.json | 6 + .../read_model.json | 177 +++++++++++++++++ .../signed_bundle.json | 146 ++++++++++++++ docs/pcs-labtrust-import.md | 91 +++++++++ docs/pcs-rendering-contract.md | 66 +++++++ pipeline/pyproject.toml | 4 + pipeline/src/sm_pipeline/cli/__init__.py | 6 + pipeline/src/sm_pipeline/cli/pcs.py | 66 +++++++ .../src/sm_pipeline/pcs_import/__init__.py | 8 + .../pcs_import/artifact_normalizer.py | 153 +++++++++++++++ .../sm_pipeline/pcs_import/portal_export.py | 49 +++++ .../science_claim_bundle_importer.py | 94 +++++++++ .../verification_result_importer.py | 35 ++++ .../src/sm_pipeline/pcs_validate/__init__.py | 13 ++ .../sm_pipeline/pcs_validate/stale_checker.py | 40 ++++ .../src/sm_pipeline/pcs_validate/validator.py | 138 +++++++++++++ portal/.generated/pcs-export.json | 185 ++++++++++++++++++ portal/app/layout.tsx | 3 + portal/app/page.tsx | 12 -- portal/app/pcs/claims/[claimId]/page.tsx | 34 ++++ portal/app/pcs/page.tsx | 53 +++++ portal/components/SiteNav.tsx | 26 +++ portal/components/pcs/ArtifactHashTable.tsx | 46 +++++ portal/components/pcs/AssumptionSetView.tsx | 41 ++++ portal/components/pcs/ClaimArtifactView.tsx | 48 +++++ portal/components/pcs/LimitationNotice.tsx | 25 +++ portal/components/pcs/PcsClaimPage.tsx | 45 +++++ portal/components/pcs/ReplayCommand.tsx | 46 +++++ portal/components/pcs/RuntimeReceiptView.tsx | 30 +++ portal/components/pcs/SourceRepositories.tsx | 38 ++++ .../components/pcs/TraceCertificateView.tsx | 30 +++ .../components/pcs/VerificationResultView.tsx | 61 ++++++ portal/lib/pcsData.ts | 39 ++++ portal/lib/pcsTypes.ts | 77 ++++++++ pyproject.toml | 2 +- schemas/pcs/artifact_base.schema.json | 73 +++++++ schemas/pcs/science_claim_bundle.schema.json | 107 ++++++++++ .../signed_science_claim_bundle.schema.json | 29 +++ schemas/pcs/verification_result.schema.json | 48 +++++ tests/pcs/conftest.py | 8 + .../fixtures/invalid_missing_signature.json | 65 ++++++ tests/pcs/fixtures/missing_assumptions.json | 61 ++++++ .../fixtures/missing_verification_result.json | 70 +++++++ .../valid_signed_science_claim_bundle.json | 146 ++++++++++++++ tests/pcs/test_import_labtrust_bundle.py | 58 ++++++ tests/pcs/test_import_verification_result.py | 61 ++++++ tests/pcs/test_reject_invalid_bundle.py | 43 ++++ tests/pcs/test_render_pcs_claim.py | 89 +++++++++ 50 files changed, 2797 insertions(+), 17 deletions(-) create mode 100644 corpus/pcs/claims/labtrust-qc-release-claim-001/import_manifest.json create mode 100644 corpus/pcs/claims/labtrust-qc-release-claim-001/read_model.json create mode 100644 corpus/pcs/claims/labtrust-qc-release-claim-001/signed_bundle.json create mode 100644 docs/pcs-labtrust-import.md create mode 100644 docs/pcs-rendering-contract.md create mode 100644 pipeline/src/sm_pipeline/cli/pcs.py create mode 100644 pipeline/src/sm_pipeline/pcs_import/__init__.py create mode 100644 pipeline/src/sm_pipeline/pcs_import/artifact_normalizer.py create mode 100644 pipeline/src/sm_pipeline/pcs_import/portal_export.py create mode 100644 pipeline/src/sm_pipeline/pcs_import/science_claim_bundle_importer.py create mode 100644 pipeline/src/sm_pipeline/pcs_import/verification_result_importer.py create mode 100644 pipeline/src/sm_pipeline/pcs_validate/__init__.py create mode 100644 pipeline/src/sm_pipeline/pcs_validate/stale_checker.py create mode 100644 pipeline/src/sm_pipeline/pcs_validate/validator.py create mode 100644 portal/.generated/pcs-export.json create mode 100644 portal/app/pcs/claims/[claimId]/page.tsx create mode 100644 portal/app/pcs/page.tsx create mode 100644 portal/components/SiteNav.tsx create mode 100644 portal/components/pcs/ArtifactHashTable.tsx create mode 100644 portal/components/pcs/AssumptionSetView.tsx create mode 100644 portal/components/pcs/ClaimArtifactView.tsx create mode 100644 portal/components/pcs/LimitationNotice.tsx create mode 100644 portal/components/pcs/PcsClaimPage.tsx create mode 100644 portal/components/pcs/ReplayCommand.tsx create mode 100644 portal/components/pcs/RuntimeReceiptView.tsx create mode 100644 portal/components/pcs/SourceRepositories.tsx create mode 100644 portal/components/pcs/TraceCertificateView.tsx create mode 100644 portal/components/pcs/VerificationResultView.tsx create mode 100644 portal/lib/pcsData.ts create mode 100644 portal/lib/pcsTypes.ts create mode 100644 schemas/pcs/artifact_base.schema.json create mode 100644 schemas/pcs/science_claim_bundle.schema.json create mode 100644 schemas/pcs/signed_science_claim_bundle.schema.json create mode 100644 schemas/pcs/verification_result.schema.json create mode 100644 tests/pcs/conftest.py create mode 100644 tests/pcs/fixtures/invalid_missing_signature.json create mode 100644 tests/pcs/fixtures/missing_assumptions.json create mode 100644 tests/pcs/fixtures/missing_verification_result.json create mode 100644 tests/pcs/fixtures/valid_signed_science_claim_bundle.json create mode 100644 tests/pcs/test_import_labtrust_bundle.py create mode 100644 tests/pcs/test_import_verification_result.py create mode 100644 tests/pcs/test_reject_invalid_bundle.py create mode 100644 tests/pcs/test_render_pcs_claim.py diff --git a/.gitignore b/.gitignore index d4d5b48..372314c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,10 @@ # ============================================================================= # Scientific Memory — ignore rules # ============================================================================= -# Note: `portal/.generated/corpus-export.json` is intentionally TRACKED so -# `pnpm --dir portal build` and portal CI succeed without a prior -# `just export-portal-data` step. Do not add `portal/.generated/` here unless -# you change CI to generate the bundle first. +# Note: `portal/.generated/corpus-export.json` and `portal/.generated/pcs-export.json` +# are intentionally TRACKED so `pnpm --dir portal build` and portal CI succeed +# without a prior export step. Do not add `portal/.generated/` here unless you +# change CI to generate those bundles first. # ----------------------------------------------------------------------------- # Lean / Lake diff --git a/JUSTFILE b/JUSTFILE index 9dbf0de..e7f3502 100644 --- a/JUSTFILE +++ b/JUSTFILE @@ -61,6 +61,11 @@ test: uv run --project kernels/adsorption pytest bash tests/smoke/test_repo_bootstrap.sh +# PCS LabTrust import contract tests (also run via `just test`) +test-pcs: + @echo "==> test-pcs" + uv run --project pipeline pytest ../tests/pcs + benchmark: uv run --project pipeline python -m sm_pipeline.cli benchmark @@ -170,3 +175,13 @@ mcp-server: # Derived metrics from corpus (SPEC 12): median intake time, dependency reuse, symbol conflict metrics *ARGS: uv run --project pipeline python -m sm_pipeline.cli metrics {{ARGS}} + +# PCS LabTrust v0.1: import, validate, and render proof-carrying science claims +pcs-import-bundle BUNDLE: + uv run --project pipeline python -m sm_pipeline.cli pcs-import-bundle --bundle {{BUNDLE}} + +pcs-validate-bundle BUNDLE: + uv run --project pipeline python -m sm_pipeline.cli pcs-validate-bundle --bundle {{BUNDLE}} + +pcs-render-claim CLAIM_ID: + uv run --project pipeline python -m sm_pipeline.cli pcs-render-claim --claim-id {{CLAIM_ID}} diff --git a/corpus/pcs/claims/labtrust-qc-release-claim-001/import_manifest.json b/corpus/pcs/claims/labtrust-qc-release-claim-001/import_manifest.json new file mode 100644 index 0000000..8b9d7a2 --- /dev/null +++ b/corpus/pcs/claims/labtrust-qc-release-claim-001/import_manifest.json @@ -0,0 +1,6 @@ +{ + "claim_id": "labtrust-qc-release-claim-001", + "imported_from": "C:\\Users\\mateo\\scientific-memory\\tests\\pcs\\fixtures\\valid_signed_science_claim_bundle.json", + "warnings": [], + "stale_artifacts": [] +} diff --git a/corpus/pcs/claims/labtrust-qc-release-claim-001/read_model.json b/corpus/pcs/claims/labtrust-qc-release-claim-001/read_model.json new file mode 100644 index 0000000..becd8c5 --- /dev/null +++ b/corpus/pcs/claims/labtrust-qc-release-claim-001/read_model.json @@ -0,0 +1,177 @@ +{ + "artifact_hashes": [ + { + "algorithm": "sha256", + "digest": "sha256:claim-artifact-001", + "name": "claim_digest", + "source_artifact": "labtrust-qc-release-claim-001" + }, + { + "algorithm": "sha256", + "digest": "sha256:trace-json-qc-release", + "name": "trace_hash", + "source_artifact": "runtime-receipt-qc-release" + }, + { + "algorithm": "sha256", + "digest": "sha256:events-qc-release", + "name": "events_hash", + "source_artifact": "runtime-receipt-qc-release" + }, + { + "algorithm": "sha256", + "digest": "sha256:temporal-policy-qc-release", + "name": "policy_hash", + "source_artifact": "trace-certificate-qc-release" + }, + { + "algorithm": "sha256", + "digest": "sha256:qc-release-stl", + "name": "spec_hash", + "source_artifact": "trace-certificate-qc-release" + } + ], + "assumption_set": { + "assumptions": [ + { + "id": "assume-sim-lab", + "kind": "simulation_scope", + "status": "RuntimeObserved", + "text": "The environment is the LabTrust-Gym simulated hospital laboratory, not a production clinical site." + }, + { + "id": "assume-qc-policy", + "kind": "policy", + "status": "CertificateChecked", + "text": "QC release policy template hospital_lab/qc_release.stl governs acceptable release transitions." + } + ], + "created_at": "2026-05-01T11:00:00Z", + "id": "labtrust-qc-release-assumptions", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "schema_version": "AssumptionSet.v0", + "signature_or_digest": "sha256:assumption-set-001", + "source_commit": "labtrust-qc-release-demo", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "status": "RuntimeObserved" + }, + "bundle_signature_or_digest": "sha256:signed-bundle-labtrust-demo-001", + "claim": { + "created_at": "2026-05-01T11:00:00Z", + "guarantee_types": { + "certificate_checked": true, + "empirically_measured": false, + "formally_checked": true, + "human_reviewed": false, + "runtime_observed": true, + "unchecked_advisory": false + }, + "id": "labtrust-qc-release-claim-001", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "signature_or_digest": "sha256:claim-artifact-001", + "status": "RuntimeChecked", + "text": "The qc-release workflow run observed protocol-compliant specimen handling and release gating in the LabTrust-Gym hospital-lab simulation." + }, + "claim_id": "labtrust-qc-release-claim-001", + "limitation_notice": "This artifact is a proof-carrying simulation result. It demonstrates protocol-level and runtime-evidence verification inside LabTrust-Gym. It is not a clinical validation, production medical certification, or guarantee about a real hospital laboratory.", + "limitations": [ + "This artifact is a proof-carrying simulation result. It demonstrates protocol-level and runtime-evidence verification inside LabTrust-Gym. It is not a clinical validation, production medical certification, or guarantee about a real hospital laboratory." + ], + "reproduce_commands": [ + "labtrust run-demo qc-release", + "labtrust export-pcs --run runs/qc-release --out science_claim_bundle.pending.json" + ], + "runtime_receipt": { + "created_at": "2026-05-01T11:15:00Z", + "events_hash": "sha256:events-qc-release", + "id": "runtime-receipt-qc-release", + "payload": { + "release_gate": "passed", + "run_id": "runs/qc-release", + "steps_observed": 42 + }, + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "schema_version": "RuntimeReceipt.v0", + "signature_or_digest": "sha256:runtime-receipt-001", + "source_commit": "labtrust-qc-release-demo", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "status": "RuntimeChecked", + "summary": "Observed workflow steps, instrument events, and release gate transitions for qc-release.", + "trace_hash": "sha256:trace-json-qc-release" + }, + "schema_version": "PcsClaimReadModel.v0", + "source_repositories": [ + { + "source_commit": "labtrust-qc-release-demo", + "source_repo": "https://github.com/fraware/LabTrust-Gym" + }, + { + "source_commit": "certifyedge-demo", + "source_repo": "https://github.com/fraware/CertifyEdge" + }, + { + "source_commit": "abc123def456", + "source_repo": "https://github.com/SentinelOps-CI/provability-fabric" + } + ], + "trace_certificate": { + "created_at": "2026-05-01T11:20:00Z", + "id": "trace-certificate-qc-release", + "payload": { + "certificate_status": "valid", + "spec": "templates/hospital_lab/qc_release.stl" + }, + "policy_hash": "sha256:temporal-policy-qc-release", + "producer": "CertifyEdge", + "producer_version": "0.1.0", + "schema_version": "TraceCertificate.v0", + "signature_or_digest": "sha256:trace-certificate-001", + "source_commit": "certifyedge-demo", + "source_repo": "https://github.com/fraware/CertifyEdge", + "spec_hash": "sha256:qc-release-stl", + "status": "CertificateChecked", + "summary": "Temporal certificate attests trace.json satisfies qc_release.stl." + }, + "verification_result": { + "checks": [ + { + "detail": "RuntimeReceipt.v0 digest matches bundle linkage.", + "guarantee_type": "runtime_observed", + "id": "check-runtime-receipt", + "name": "Runtime receipt hash chain", + "outcome": "pass" + }, + { + "detail": "TraceCertificate.v0 status CertificateChecked.", + "guarantee_type": "certificate_checked", + "id": "check-trace-certificate", + "name": "Trace certificate temporal policy", + "outcome": "pass" + }, + { + "detail": "Provability Fabric signing check passed.", + "guarantee_type": "formally_checked", + "id": "check-bundle-signature", + "name": "Bundle signature", + "outcome": "pass" + } + ], + "created_at": "2026-05-01T12:00:00Z", + "id": "verification-result-labtrust-qc-release", + "overall_outcome": "pass", + "producer": "provability-fabric", + "producer_version": "0.1.0", + "schema_version": "VerificationResult.v0", + "signature_or_digest": "sha256:verification-result-001", + "source_commit": "abc123def456", + "source_repo": "https://github.com/SentinelOps-CI/provability-fabric", + "status": "ProofChecked" + }, + "verify_commands": [ + "pf verify science-claim signed_science_claim_bundle.json", + "just pcs-validate-bundle BUNDLE=signed_science_claim_bundle.json" + ] +} diff --git a/corpus/pcs/claims/labtrust-qc-release-claim-001/signed_bundle.json b/corpus/pcs/claims/labtrust-qc-release-claim-001/signed_bundle.json new file mode 100644 index 0000000..8d32bc7 --- /dev/null +++ b/corpus/pcs/claims/labtrust-qc-release-claim-001/signed_bundle.json @@ -0,0 +1,146 @@ +{ + "reproduce_commands": [ + "labtrust run-demo qc-release", + "labtrust export-pcs --run runs/qc-release --out science_claim_bundle.pending.json" + ], + "schema_version": "SignedScienceClaimBundle.v0", + "science_claim_bundle": { + "assumption_set": { + "assumptions": [ + { + "id": "assume-sim-lab", + "kind": "simulation_scope", + "status": "RuntimeObserved", + "text": "The environment is the LabTrust-Gym simulated hospital laboratory, not a production clinical site." + }, + { + "id": "assume-qc-policy", + "kind": "policy", + "status": "CertificateChecked", + "text": "QC release policy template hospital_lab/qc_release.stl governs acceptable release transitions." + } + ], + "created_at": "2026-05-01T11:00:00Z", + "id": "labtrust-qc-release-assumptions", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "schema_version": "AssumptionSet.v0", + "signature_or_digest": "sha256:assumption-set-001", + "source_commit": "labtrust-qc-release-demo", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "status": "RuntimeObserved" + }, + "claim": { + "claim_text": "The qc-release workflow run observed protocol-compliant specimen handling and release gating in the LabTrust-Gym hospital-lab simulation.", + "created_at": "2026-05-01T11:00:00Z", + "guarantee_types": { + "certificate_checked": true, + "empirically_measured": false, + "formally_checked": true, + "human_reviewed": false, + "runtime_observed": true, + "unchecked_advisory": false + }, + "id": "labtrust-qc-release-claim-001", + "output_hashes": [ + { + "algorithm": "sha256", + "digest": "sha256:claim-artifact-001", + "name": "claim_digest" + } + ], + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "schema_version": "ClaimArtifact.v0", + "signature_or_digest": "sha256:claim-artifact-001", + "source_commit": "labtrust-qc-release-demo", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "status": "RuntimeChecked" + }, + "created_at": "2026-05-01T11:30:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "runtime_receipt": { + "created_at": "2026-05-01T11:15:00Z", + "events_hash": "sha256:events-qc-release", + "id": "runtime-receipt-qc-release", + "payload": { + "release_gate": "passed", + "run_id": "runs/qc-release", + "steps_observed": 42 + }, + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "schema_version": "RuntimeReceipt.v0", + "signature_or_digest": "sha256:runtime-receipt-001", + "source_commit": "labtrust-qc-release-demo", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "status": "RuntimeChecked", + "summary": "Observed workflow steps, instrument events, and release gate transitions for qc-release.", + "trace_hash": "sha256:trace-json-qc-release" + }, + "schema_version": "ScienceClaimBundle.v0", + "signature_or_digest": "sha256:science-claim-bundle-001", + "source_commit": "labtrust-qc-release-demo", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "status": "RuntimeChecked", + "trace_certificate": { + "created_at": "2026-05-01T11:20:00Z", + "id": "trace-certificate-qc-release", + "payload": { + "certificate_status": "valid", + "spec": "templates/hospital_lab/qc_release.stl" + }, + "policy_hash": "sha256:temporal-policy-qc-release", + "producer": "CertifyEdge", + "producer_version": "0.1.0", + "schema_version": "TraceCertificate.v0", + "signature_or_digest": "sha256:trace-certificate-001", + "source_commit": "certifyedge-demo", + "source_repo": "https://github.com/fraware/CertifyEdge", + "spec_hash": "sha256:qc-release-stl", + "status": "CertificateChecked", + "summary": "Temporal certificate attests trace.json satisfies qc_release.stl." + } + }, + "signature_or_digest": "sha256:signed-bundle-labtrust-demo-001", + "verification_result": { + "checks": [ + { + "detail": "RuntimeReceipt.v0 digest matches bundle linkage.", + "guarantee_type": "runtime_observed", + "id": "check-runtime-receipt", + "name": "Runtime receipt hash chain", + "outcome": "pass" + }, + { + "detail": "TraceCertificate.v0 status CertificateChecked.", + "guarantee_type": "certificate_checked", + "id": "check-trace-certificate", + "name": "Trace certificate temporal policy", + "outcome": "pass" + }, + { + "detail": "Provability Fabric signing check passed.", + "guarantee_type": "formally_checked", + "id": "check-bundle-signature", + "name": "Bundle signature", + "outcome": "pass" + } + ], + "created_at": "2026-05-01T12:00:00Z", + "id": "verification-result-labtrust-qc-release", + "overall_outcome": "pass", + "producer": "provability-fabric", + "producer_version": "0.1.0", + "schema_version": "VerificationResult.v0", + "signature_or_digest": "sha256:verification-result-001", + "source_commit": "abc123def456", + "source_repo": "https://github.com/SentinelOps-CI/provability-fabric", + "status": "ProofChecked" + }, + "verify_commands": [ + "pf verify science-claim signed_science_claim_bundle.json", + "just pcs-validate-bundle BUNDLE=signed_science_claim_bundle.json" + ] +} diff --git a/docs/pcs-labtrust-import.md b/docs/pcs-labtrust-import.md new file mode 100644 index 0000000..4478883 --- /dev/null +++ b/docs/pcs-labtrust-import.md @@ -0,0 +1,91 @@ +# PCS LabTrust import + +Scientific Memory imports **signed** `ScienceClaimBundle` artifacts produced by the LabTrust v0.1 demonstration workflow and verified or signed by Provability Fabric. + +## Expected input + +- File: `signed_science_claim_bundle.json` +- Top-level `schema_version`: `SignedScienceClaimBundle.v0` +- Nested `science_claim_bundle` with `ScienceClaimBundle.v0` +- Optional top-level `verification_result` (`VerificationResult.v0`) +- Top-level `signature_or_digest` + +Canonical artifact vocabulary is defined in [pcs-core](https://github.com/SentinelOps-CI/pcs-core). This repository validates against vendored JSON Schemas under `schemas/pcs/` and will call `pcs_core` when that package is installed. + +## Commands + +```bash +just pcs-validate-bundle BUNDLE=path/to/signed_science_claim_bundle.json +just pcs-import-bundle BUNDLE=path/to/signed_science_claim_bundle.json +just pcs-render-claim CLAIM_ID= +``` + +`pcs-import-bundle` writes: + +- `corpus/pcs/claims//signed_bundle.json` — preserved signed input +- `corpus/pcs/claims//read_model.json` — portal read model +- `corpus/pcs/claims//import_manifest.json` — warnings and provenance + +`pcs-render-claim` refreshes `portal/.generated/pcs-export.json` for static portal build. + +## Import behavior + +| Behavior | Detail | +|----------|--------| +| Validation | JSON Schema + optional `pcs_core` hook | +| Reject invalid | Default (`strict=true`) | +| Preserve IDs | Claim, assumption set, receipt, certificate IDs unchanged | +| Preserve provenance | `source_repo`, `source_commit`, `signature_or_digest` on each artifact | +| Preserve checks | VerificationResult `checks` list stored verbatim | +| Warn: no VerificationResult | Import continues; portal shows advisory | +| Warn: certificate not checked | When `trace_certificate.status` ≠ `CertificateChecked` | +| Reject: empty assumptions | `assumption_set.assumptions` must be non-empty | + +## End-to-end LabTrust flow (reference) + +```bash +# LabTrust-Gym +labtrust run-demo qc-release +labtrust export-trace --run runs/qc-release --out trace.json +labtrust export-runtime-receipt --run runs/qc-release --out runtime_receipt.json +labtrust export-pcs --run runs/qc-release --out science_claim_bundle.pending.json + +# CertifyEdge +certifyedge emit-pcs-certificate \ + --spec templates/hospital_lab/qc_release.stl \ + --trace trace.json \ + --out trace_certificate.json + +labtrust attach-certificate \ + --bundle science_claim_bundle.pending.json \ + --certificate trace_certificate.json \ + --out science_claim_bundle.certified.json + +# Provability Fabric +pf verify science-claim science_claim_bundle.certified.json +pf sign science-claim science_claim_bundle.certified.json \ + --out signed_science_claim_bundle.json + +# Scientific Memory +just pcs-import-bundle BUNDLE=signed_science_claim_bundle.json +just pcs-render-claim CLAIM_ID= +``` + +## What is checked vs not checked + +**Checked (when present in bundle):** + +- Schema shape for signed bundle, science claim bundle, and verification result +- Non-empty assumption set +- Artifact hash fields on nested artifacts (surfaced in portal hash table) +- Provability Fabric verification checks (listed under Verification Result) +- Runtime receipt and trace certificate status values (displayed, not re-verified) + +**Not checked by Scientific Memory v0.1:** + +- Live LabTrust simulation re-run +- CertifyEdge certificate re-generation +- Temporal trace re-validation against production hospital systems +- Clinical or production medical certification + +See [pcs-rendering-contract.md](./pcs-rendering-contract.md) for portal section requirements. diff --git a/docs/pcs-rendering-contract.md b/docs/pcs-rendering-contract.md new file mode 100644 index 0000000..57b86ec --- /dev/null +++ b/docs/pcs-rendering-contract.md @@ -0,0 +1,66 @@ +# PCS rendering contract + +Every LabTrust PCS claim page at `/pcs/claims/` must render the following sections in order. + +| # | Section title | Component | Data source | +|---|---------------|-----------|-------------| +| 1 | Claim | `ClaimArtifactView` | `read_model.claim` | +| 2 | Assumptions | `AssumptionSetView` | `read_model.assumption_set` | +| 3 | Runtime Evidence | `RuntimeReceiptView` | `read_model.runtime_receipt` | +| 4 | Temporal Certificate | `TraceCertificateView` | `read_model.trace_certificate` | +| 5 | Verification Result | `VerificationResultView` | `read_model.verification_result` | +| 6 | Artifact Hashes | `ArtifactHashTable` | `read_model.artifact_hashes` | +| 7 | Source Repositories | `SourceRepositories` | `read_model.source_repositories` | +| 8 | Reproduce / Verify | `ReplayCommand` | `reproduce_commands`, `verify_commands` | +| 9 | Limitations | `LimitationNotice` | `limitation_notice` (+ optional `limitations`) | + +## Guarantee-type separation + +The claim section must show boolean flags for: + +- `formally_checked` +- `certificate_checked` +- `runtime_observed` +- `empirically_measured` +- `human_reviewed` +- `unchecked_advisory` + +Values come from `ClaimArtifact.guarantee_types` when present; otherwise they are inferred from nested artifact statuses and verification checks. + +## Required limitation notice + +Every page must display the following notice verbatim (or substantively identical wording): + +> This artifact is a proof-carrying simulation result. It demonstrates protocol-level and runtime-evidence verification inside LabTrust-Gym. It is not a clinical validation, production medical certification, or guarantee about a real hospital laboratory. + +The canonical string is defined in `sm_pipeline.pcs_import.artifact_normalizer.LIMITATION_NOTICE`. + +## Status visibility + +Status enums must be visible for: + +- Claim artifact +- Assumption set and individual assumptions (when provided) +- Runtime receipt +- Trace certificate +- Verification result and each check outcome + +Statuses use the pcs-core canonical enum (`Draft`, `RuntimeObserved`, `CertificateChecked`, etc.). + +## Reproduce / verify commands + +Commands are copied from the signed bundle (top-level or `science_claim_bundle` fields) without modification. They are shown as shell snippets for external verification, for example: + +- `pf verify science-claim …` +- `just pcs-validate-bundle BUNDLE=…` + +## Test IDs + +Portal components expose `data-testid` attributes for contract tests: + +- `pcs-claim-page` +- `pcs-section-claim`, `pcs-section-assumptions`, … +- `pcs-limitation-notice` +- `pcs-hash-table`, `pcs-source-repo`, `pcs-source-commit` + +Python tests in `tests/pcs/` validate import behavior and read-model completeness. diff --git a/pipeline/pyproject.toml b/pipeline/pyproject.toml index 038e469..d068f10 100644 --- a/pipeline/pyproject.toml +++ b/pipeline/pyproject.toml @@ -26,5 +26,9 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/sm_pipeline"] +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests", "../tests/pcs"] + [tool.ruff] line-length = 100 diff --git a/pipeline/src/sm_pipeline/cli/__init__.py b/pipeline/src/sm_pipeline/cli/__init__.py index 719427d..b4e2a71 100644 --- a/pipeline/src/sm_pipeline/cli/__init__.py +++ b/pipeline/src/sm_pipeline/cli/__init__.py @@ -9,6 +9,7 @@ ingest, llm_proposals, metrics, + pcs, publish, validate_cmd, ) @@ -60,3 +61,8 @@ app.command("llm-apply-mapping-proposals")(llm_proposals.llm_apply_mapping_proposals) app.command("llm-lean-proposals")(llm_proposals.llm_lean_proposals) app.command("llm-lean-proposals-to-apply-bundle")(llm_proposals.llm_lean_proposals_to_apply_bundle) + +# PCS LabTrust import (v0.1) +app.command("pcs-import-bundle")(pcs.pcs_import_bundle) +app.command("pcs-validate-bundle")(pcs.pcs_validate_bundle) +app.command("pcs-render-claim")(pcs.pcs_render_claim) diff --git a/pipeline/src/sm_pipeline/cli/pcs.py b/pipeline/src/sm_pipeline/cli/pcs.py new file mode 100644 index 0000000..649bd4a --- /dev/null +++ b/pipeline/src/sm_pipeline/cli/pcs.py @@ -0,0 +1,66 @@ +"""CLI: PCS LabTrust bundle import, validate, and portal render.""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.console import Console + +from sm_pipeline.pcs_import.portal_export import write_pcs_portal_export +from sm_pipeline.pcs_import.science_claim_bundle_importer import import_signed_bundle +from sm_pipeline.pcs_validate.validator import BundleValidationError, validate_signed_bundle + +console = Console() +_REPO_ROOT = Path(__file__).resolve().parents[4] + + +def pcs_import_bundle( + bundle: Path = typer.Option(..., "--bundle", "-b", help="Signed bundle JSON path"), + strict: bool = typer.Option(True, help="Reject invalid bundles"), +) -> None: + """Import a signed LabTrust PCS bundle into corpus/pcs/claims/.""" + try: + result = import_signed_bundle(bundle, repo_root=_REPO_ROOT, strict=strict) + except BundleValidationError as exc: + console.print(f"[red]Import rejected:[/red] {exc}") + raise typer.Exit(code=1) from exc + + write_pcs_portal_export(_REPO_ROOT) + console.print(f"[green]Imported claim[/green] {result.claim_id}") + console.print(f" -> {result.import_dir}") + for w in result.warnings: + console.print(f"[yellow]Warning:[/yellow] {w}") + + +def pcs_validate_bundle( + bundle: Path = typer.Option(..., "--bundle", "-b", help="Signed bundle JSON path"), +) -> None: + """Validate a signed bundle without importing.""" + import json + + raw = json.loads(bundle.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + console.print("[red]Bundle must be a JSON object[/red]") + raise typer.Exit(code=1) + try: + warnings = validate_signed_bundle(raw, repo_root=_REPO_ROOT, strict=True) + except BundleValidationError as exc: + console.print(f"[red]Invalid bundle:[/red] {exc}") + raise typer.Exit(code=1) from exc + + console.print("[green]Bundle is valid[/green]") + for w in warnings: + console.print(f"[yellow]Warning:[/yellow] {w}") + + +def pcs_render_claim( + claim_id: str = typer.Option(..., "--claim-id", help="PCS claim artifact id"), +) -> None: + """Export portal PCS read model for rendering.""" + try: + out = write_pcs_portal_export(_REPO_ROOT, claim_id=claim_id) + except FileNotFoundError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) from exc + console.print(f"[green]PCS portal export[/green] -> {out}") diff --git a/pipeline/src/sm_pipeline/pcs_import/__init__.py b/pipeline/src/sm_pipeline/pcs_import/__init__.py new file mode 100644 index 0000000..44d260d --- /dev/null +++ b/pipeline/src/sm_pipeline/pcs_import/__init__.py @@ -0,0 +1,8 @@ +"""Import signed LabTrust PCS bundles into Scientific Memory.""" + +from sm_pipeline.pcs_import.science_claim_bundle_importer import ( + ImportResult, + import_signed_bundle, +) + +__all__ = ["ImportResult", "import_signed_bundle"] diff --git a/pipeline/src/sm_pipeline/pcs_import/artifact_normalizer.py b/pipeline/src/sm_pipeline/pcs_import/artifact_normalizer.py new file mode 100644 index 0000000..98c9ebf --- /dev/null +++ b/pipeline/src/sm_pipeline/pcs_import/artifact_normalizer.py @@ -0,0 +1,153 @@ +"""Normalize PCS nested artifacts into a portal read model.""" + +from __future__ import annotations + +from typing import Any + +GUARANTEE_KEYS = ( + "formally_checked", + "certificate_checked", + "runtime_observed", + "empirically_measured", + "human_reviewed", + "unchecked_advisory", +) + +LIMITATION_NOTICE = ( + "This artifact is a proof-carrying simulation result. It demonstrates " + "protocol-level and runtime-evidence verification inside LabTrust-Gym. It is " + "not a clinical validation, production medical certification, or guarantee " + "about a real hospital laboratory." +) + + +def _hash_rows(artifact: dict[str, Any] | None) -> list[dict[str, str]]: + if not isinstance(artifact, dict): + return [] + rows: list[dict[str, str]] = [] + for field in ("input_hashes", "output_hashes"): + entries = artifact.get(field) + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, dict): + continue + name = str(entry.get("name") or entry.get("id") or "artifact") + digest = str(entry.get("digest") or entry.get("hash") or "") + if digest: + rows.append( + { + "name": name, + "digest": digest, + "algorithm": str(entry.get("algorithm") or "sha256"), + "source_artifact": str(artifact.get("id") or field), + } + ) + for key in ("trace_hash", "policy_hash", "events_hash", "spec_hash"): + val = artifact.get(key) + if isinstance(val, str) and val.strip(): + rows.append( + { + "name": key, + "digest": val.strip(), + "algorithm": "sha256", + "source_artifact": str(artifact.get("id") or "metadata"), + } + ) + return rows + + +def _source_metadata(artifact: dict[str, Any] | None) -> dict[str, str]: + if not isinstance(artifact, dict): + return {"source_repo": "", "source_commit": ""} + return { + "source_repo": str(artifact.get("source_repo") or ""), + "source_commit": str(artifact.get("source_commit") or ""), + } + + +def normalize_signed_bundle(bundle: dict[str, Any]) -> dict[str, Any]: + """Build durable portal read model from a signed ScienceClaimBundle.""" + scb = bundle["science_claim_bundle"] + claim = scb["claim"] + claim_id = str(claim["id"]) + + vr = bundle.get("verification_result") + if vr is None: + vr = scb.get("verification_result") + + assumption_set = scb.get("assumption_set") or {} + runtime_receipt = scb.get("runtime_receipt") or {} + trace_certificate = scb.get("trace_certificate") or {} + + hash_artifacts = [claim, assumption_set, runtime_receipt, trace_certificate] + if isinstance(vr, dict): + hash_artifacts.append(vr) + evidence = scb.get("evidence_bundle") + if isinstance(evidence, dict): + hash_artifacts.append(evidence) + + artifact_hashes: list[dict[str, str]] = [] + for art in hash_artifacts: + artifact_hashes.extend(_hash_rows(art if isinstance(art, dict) else None)) + + sources: list[dict[str, str]] = [] + seen: set[tuple[str, str]] = set() + for art in hash_artifacts: + if not isinstance(art, dict): + continue + meta = _source_metadata(art) + key = (meta["source_repo"], meta["source_commit"]) + if key in seen or not meta["source_repo"]: + continue + seen.add(key) + sources.append(meta) + + reproduce = list(bundle.get("reproduce_commands") or scb.get("reproduce_commands") or []) + verify = list(bundle.get("verify_commands") or scb.get("verify_commands") or []) + + limitations = list(scb.get("limitations") or []) + if LIMITATION_NOTICE not in limitations: + limitations = [LIMITATION_NOTICE, *limitations] + + guarantee_types = claim.get("guarantee_types") + if not isinstance(guarantee_types, dict): + guarantee_types = {k: False for k in GUARANTEE_KEYS} + guarantee_types["runtime_observed"] = str(runtime_receipt.get("status")) in ( + "RuntimeObserved", + "RuntimeChecked", + ) + guarantee_types["certificate_checked"] = str(trace_certificate.get("status")) == ( + "CertificateChecked" + ) + if isinstance(vr, dict): + for check in vr.get("checks") or []: + if not isinstance(check, dict): + continue + gt = str(check.get("guarantee_type") or "") + if gt in GUARANTEE_KEYS and check.get("outcome") == "pass": + guarantee_types[gt] = True + + return { + "schema_version": "PcsClaimReadModel.v0", + "claim_id": claim_id, + "claim": { + "id": claim_id, + "text": str(claim.get("claim_text") or ""), + "status": str(claim.get("status") or ""), + "signature_or_digest": str(claim.get("signature_or_digest") or ""), + "guarantee_types": guarantee_types, + **{k: claim.get(k) for k in ("producer", "producer_version", "created_at")}, + }, + "assumption_set": assumption_set, + "runtime_receipt": runtime_receipt, + "trace_certificate": trace_certificate, + "verification_result": vr, + "artifact_hashes": artifact_hashes, + "source_repositories": sources, + "reproduce_commands": reproduce, + "verify_commands": verify, + "limitations": limitations, + "limitation_notice": LIMITATION_NOTICE, + "bundle_signature_or_digest": str(bundle.get("signature_or_digest") or ""), + } diff --git a/pipeline/src/sm_pipeline/pcs_import/portal_export.py b/pipeline/src/sm_pipeline/pcs_import/portal_export.py new file mode 100644 index 0000000..7eabe36 --- /dev/null +++ b/pipeline/src/sm_pipeline/pcs_import/portal_export.py @@ -0,0 +1,49 @@ +"""Export PCS claims for the Next.js portal.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def build_pcs_portal_export(repo_root: Path) -> dict[str, Any]: + root = repo_root.resolve() + claims_dir = root / "corpus" / "pcs" / "claims" + claims: dict[str, dict[str, Any]] = {} + claim_ids: list[str] = [] + + if claims_dir.is_dir(): + for claim_dir in sorted(claims_dir.iterdir()): + if not claim_dir.is_dir(): + continue + read_model_path = claim_dir / "read_model.json" + if not read_model_path.is_file(): + continue + data = json.loads(read_model_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + continue + claim_id = str(data.get("claim_id") or claim_dir.name) + claims[claim_id] = data + claim_ids.append(claim_id) + + return { + "schema_version": "PcsPortalExport.v0", + "claim_ids": sorted(claim_ids), + "claims": claims, + } + + +def write_pcs_portal_export(repo_root: Path, claim_id: str | None = None) -> Path: + """Write portal/.generated/pcs-export.json (all claims or validate one exists).""" + root = repo_root.resolve() + export = build_pcs_portal_export(root) + if claim_id is not None and claim_id not in export["claims"]: + raise FileNotFoundError( + f"No imported PCS claim with id {claim_id!r}; run pcs-import-bundle first." + ) + out_dir = root / "portal" / ".generated" + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / "pcs-export.json" + out_path.write_text(json.dumps(export, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return out_path diff --git a/pipeline/src/sm_pipeline/pcs_import/science_claim_bundle_importer.py b/pipeline/src/sm_pipeline/pcs_import/science_claim_bundle_importer.py new file mode 100644 index 0000000..d479c26 --- /dev/null +++ b/pipeline/src/sm_pipeline/pcs_import/science_claim_bundle_importer.py @@ -0,0 +1,94 @@ +"""Import signed LabTrust ScienceClaimBundle artifacts.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from sm_pipeline.pcs_import.artifact_normalizer import normalize_signed_bundle +from sm_pipeline.pcs_validate.stale_checker import find_stale_artifacts +from sm_pipeline.pcs_validate.validator import BundleValidationError, validate_signed_bundle + + +@dataclass +class ImportResult: + claim_id: str + import_dir: Path + warnings: list[str] = field(default_factory=list) + stale_artifacts: list[str] = field(default_factory=list) + + +def _repo_root(repo_root: Path | None) -> Path: + if repo_root is not None: + return repo_root.resolve() + return Path(__file__).resolve().parents[4] + + +def _import_root(repo_root: Path) -> Path: + return repo_root / "corpus" / "pcs" / "claims" + + +def import_signed_bundle( + bundle_path: Path, + *, + repo_root: Path | None = None, + strict: bool = True, + write: bool = True, +) -> ImportResult: + """ + Validate and import a signed science claim bundle. + + Preserves artifact IDs, source_repo, source_commit, signature_or_digest, + and verification checks. Rejects invalid bundles when strict=True. + """ + root = _repo_root(repo_root) + raw = json.loads(bundle_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise BundleValidationError("Bundle must be a JSON object") + + warnings = validate_signed_bundle(raw, repo_root=root, strict=strict) + stale = find_stale_artifacts(raw) + if stale: + warnings.extend(f"Stale or deprecated artifact: {p}" for p in stale) + + read_model = normalize_signed_bundle(raw) + claim_id = read_model["claim_id"] + import_dir = _import_root(root) / claim_id + + if write: + import_dir.mkdir(parents=True, exist_ok=True) + (import_dir / "signed_bundle.json").write_text( + json.dumps(raw, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + (import_dir / "read_model.json").write_text( + json.dumps(read_model, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + manifest = { + "claim_id": claim_id, + "imported_from": str(bundle_path.resolve()), + "warnings": warnings, + "stale_artifacts": stale, + } + (import_dir / "import_manifest.json").write_text( + json.dumps(manifest, indent=2) + "\n", + encoding="utf-8", + ) + + return ImportResult( + claim_id=claim_id, + import_dir=import_dir, + warnings=warnings, + stale_artifacts=stale, + ) + + +def load_read_model(repo_root: Path, claim_id: str) -> dict[str, Any] | None: + path = _import_root(repo_root.resolve()) / claim_id / "read_model.json" + if not path.is_file(): + return None + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else None diff --git a/pipeline/src/sm_pipeline/pcs_import/verification_result_importer.py b/pipeline/src/sm_pipeline/pcs_import/verification_result_importer.py new file mode 100644 index 0000000..0b309fe --- /dev/null +++ b/pipeline/src/sm_pipeline/pcs_import/verification_result_importer.py @@ -0,0 +1,35 @@ +"""Import VerificationResult.v0 artifacts (embedded or sidecar).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from sm_pipeline.pcs_validate.validator import BundleValidationError, validator_for + + +def load_verification_result(path: Path, *, repo_root: Path) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise BundleValidationError("VerificationResult must be a JSON object") + validator = validator_for("verification_result.schema.json", repo_root) + errors = [e.message for e in validator.iter_errors(data)] + if errors: + raise BundleValidationError("; ".join(errors)) + return data + + +def merge_verification_result( + bundle: dict[str, Any], + verification_result: dict[str, Any], +) -> dict[str, Any]: + """Attach verification result without mutating nested artifact IDs.""" + merged = dict(bundle) + merged["verification_result"] = verification_result + scb = merged.get("science_claim_bundle") + if isinstance(scb, dict) and scb.get("verification_result") is None: + scb_copy = dict(scb) + scb_copy["verification_result"] = verification_result + merged["science_claim_bundle"] = scb_copy + return merged diff --git a/pipeline/src/sm_pipeline/pcs_validate/__init__.py b/pipeline/src/sm_pipeline/pcs_validate/__init__.py new file mode 100644 index 0000000..8035190 --- /dev/null +++ b/pipeline/src/sm_pipeline/pcs_validate/__init__.py @@ -0,0 +1,13 @@ +"""PCS bundle validation against pcs-core (when installed) and vendored schemas.""" + +from sm_pipeline.pcs_validate.validator import ( + BundleValidationError, + collect_import_warnings, + validate_signed_bundle, +) + +__all__ = [ + "BundleValidationError", + "collect_import_warnings", + "validate_signed_bundle", +] diff --git a/pipeline/src/sm_pipeline/pcs_validate/stale_checker.py b/pipeline/src/sm_pipeline/pcs_validate/stale_checker.py new file mode 100644 index 0000000..192e41f --- /dev/null +++ b/pipeline/src/sm_pipeline/pcs_validate/stale_checker.py @@ -0,0 +1,40 @@ +"""Flag PCS artifacts whose status is Stale or Deprecated.""" + +from __future__ import annotations + +from typing import Any + + +STALE_STATUSES = frozenset({"Stale", "Deprecated"}) + + +def find_stale_artifacts(bundle: dict[str, Any]) -> list[str]: + """Return artifact paths (dot-separated) with Stale or Deprecated status.""" + stale: list[str] = [] + scb = bundle.get("science_claim_bundle") + if not isinstance(scb, dict): + return stale + + for key in ( + "claim", + "assumption_set", + "runtime_receipt", + "trace_certificate", + "evidence_bundle", + "verification_result", + ): + artifact = scb.get(key) + if isinstance(artifact, dict): + _check_artifact(f"science_claim_bundle.{key}", artifact, stale) + + vr = bundle.get("verification_result") + if isinstance(vr, dict): + _check_artifact("verification_result", vr, stale) + + return stale + + +def _check_artifact(path: str, artifact: dict[str, Any], out: list[str]) -> None: + status = str(artifact.get("status") or "") + if status in STALE_STATUSES: + out.append(path) diff --git a/pipeline/src/sm_pipeline/pcs_validate/validator.py b/pipeline/src/sm_pipeline/pcs_validate/validator.py new file mode 100644 index 0000000..5c33cc6 --- /dev/null +++ b/pipeline/src/sm_pipeline/pcs_validate/validator.py @@ -0,0 +1,138 @@ +"""Validate signed PCS bundles (pcs-core hook + vendored JSON Schema).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator +from referencing import Registry, Resource +from referencing.jsonschema import DRAFT202012 + +PCS_SCHEMA_BASE_URI = "https://scientific-memory.org/schemas/pcs/" + + +class BundleValidationError(ValueError): + """Raised when a PCS bundle fails validation.""" + + +def _repo_root_from_here() -> Path: + return Path(__file__).resolve().parents[4] + + +def _pcs_schemas_dir(repo_root: Path | None = None) -> Path: + root = repo_root or _repo_root_from_here() + return root / "schemas" / "pcs" + + +def _load_json(path: Path) -> object: + return json.loads(path.read_text(encoding="utf-8")) + + +def _build_pcs_registry(repo_root: Path) -> Registry: + registry = Registry() + for path in sorted(_pcs_schemas_dir(repo_root).glob("*.json")): + schema = _load_json(path) + schema_id = schema.get("$id") + if not isinstance(schema_id, str): + continue + registry = registry.with_resource( + schema_id, + Resource.from_contents(schema, default_specification=DRAFT202012), + ) + return registry + + +def validator_for(schema_name: str, repo_root: Path) -> Draft202012Validator: + schema_path = _pcs_schemas_dir(repo_root) / schema_name + schema = _load_json(schema_path) + registry = _build_pcs_registry(repo_root) + return Draft202012Validator(schema, registry=registry) + + +def _try_pcs_core_validate(bundle: dict[str, Any]) -> None: + """Optional pcs-core validation hook (no-op if package unavailable).""" + try: + from pcs_core.validate import validate_signed_science_claim_bundle # type: ignore + except ImportError: + try: + from pcs_core import validate_signed_science_claim_bundle # type: ignore + except ImportError: + return + validate_signed_science_claim_bundle(bundle) + + +def validate_signed_bundle( + bundle: dict[str, Any], + *, + repo_root: Path | None = None, + strict: bool = True, +) -> list[str]: + """ + Validate a signed science claim bundle. + + Returns import warnings (non-fatal). Raises BundleValidationError when strict + and validation fails. + """ + root = (repo_root or _repo_root_from_here()).resolve() + errors: list[str] = [] + + if strict: + _try_pcs_core_validate(bundle) + + validator = validator_for("signed_science_claim_bundle.schema.json", root) + for err in sorted(validator.iter_errors(bundle), key=lambda e: e.path): + errors.append(err.message) + + scb = bundle.get("science_claim_bundle") + if isinstance(scb, dict): + scb_validator = validator_for("science_claim_bundle.schema.json", root) + for err in sorted(scb_validator.iter_errors(scb), key=lambda e: e.path): + errors.append(f"science_claim_bundle: {err.message}") + + assumption_set = scb.get("assumption_set") + assumptions = ( + assumption_set.get("assumptions") + if isinstance(assumption_set, dict) + else None + ) + if not assumptions: + errors.append("science_claim_bundle.assumption_set.assumptions is required") + + vr = bundle.get("verification_result") + if vr is None and isinstance(scb, dict): + vr = scb.get("verification_result") + if isinstance(vr, dict): + vr_validator = validator_for("verification_result.schema.json", root) + for err in sorted(vr_validator.iter_errors(vr), key=lambda e: e.path): + errors.append(f"verification_result: {err.message}") + + if errors and strict: + raise BundleValidationError("; ".join(errors)) + + return collect_import_warnings(bundle) if not errors else [] + + +def collect_import_warnings(bundle: dict[str, Any]) -> list[str]: + """Non-fatal warnings required by the PCS import contract.""" + warnings: list[str] = [] + scb = bundle.get("science_claim_bundle") + if not isinstance(scb, dict): + return warnings + + vr = bundle.get("verification_result") + if vr is None: + vr = scb.get("verification_result") + if vr is None: + warnings.append("VerificationResult is absent; import proceeds with advisory only.") + + trace_cert = scb.get("trace_certificate") + if isinstance(trace_cert, dict): + status = str(trace_cert.get("status") or "") + if status != "CertificateChecked": + warnings.append( + f"trace_certificate.status is {status!r}, expected CertificateChecked." + ) + + return warnings diff --git a/portal/.generated/pcs-export.json b/portal/.generated/pcs-export.json new file mode 100644 index 0000000..c84b937 --- /dev/null +++ b/portal/.generated/pcs-export.json @@ -0,0 +1,185 @@ +{ + "claim_ids": [ + "labtrust-qc-release-claim-001" + ], + "claims": { + "labtrust-qc-release-claim-001": { + "artifact_hashes": [ + { + "algorithm": "sha256", + "digest": "sha256:claim-artifact-001", + "name": "claim_digest", + "source_artifact": "labtrust-qc-release-claim-001" + }, + { + "algorithm": "sha256", + "digest": "sha256:trace-json-qc-release", + "name": "trace_hash", + "source_artifact": "runtime-receipt-qc-release" + }, + { + "algorithm": "sha256", + "digest": "sha256:events-qc-release", + "name": "events_hash", + "source_artifact": "runtime-receipt-qc-release" + }, + { + "algorithm": "sha256", + "digest": "sha256:temporal-policy-qc-release", + "name": "policy_hash", + "source_artifact": "trace-certificate-qc-release" + }, + { + "algorithm": "sha256", + "digest": "sha256:qc-release-stl", + "name": "spec_hash", + "source_artifact": "trace-certificate-qc-release" + } + ], + "assumption_set": { + "assumptions": [ + { + "id": "assume-sim-lab", + "kind": "simulation_scope", + "status": "RuntimeObserved", + "text": "The environment is the LabTrust-Gym simulated hospital laboratory, not a production clinical site." + }, + { + "id": "assume-qc-policy", + "kind": "policy", + "status": "CertificateChecked", + "text": "QC release policy template hospital_lab/qc_release.stl governs acceptable release transitions." + } + ], + "created_at": "2026-05-01T11:00:00Z", + "id": "labtrust-qc-release-assumptions", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "schema_version": "AssumptionSet.v0", + "signature_or_digest": "sha256:assumption-set-001", + "source_commit": "labtrust-qc-release-demo", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "status": "RuntimeObserved" + }, + "bundle_signature_or_digest": "sha256:signed-bundle-labtrust-demo-001", + "claim": { + "created_at": "2026-05-01T11:00:00Z", + "guarantee_types": { + "certificate_checked": true, + "empirically_measured": false, + "formally_checked": true, + "human_reviewed": false, + "runtime_observed": true, + "unchecked_advisory": false + }, + "id": "labtrust-qc-release-claim-001", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "signature_or_digest": "sha256:claim-artifact-001", + "status": "RuntimeChecked", + "text": "The qc-release workflow run observed protocol-compliant specimen handling and release gating in the LabTrust-Gym hospital-lab simulation." + }, + "claim_id": "labtrust-qc-release-claim-001", + "limitation_notice": "This artifact is a proof-carrying simulation result. It demonstrates protocol-level and runtime-evidence verification inside LabTrust-Gym. It is not a clinical validation, production medical certification, or guarantee about a real hospital laboratory.", + "limitations": [ + "This artifact is a proof-carrying simulation result. It demonstrates protocol-level and runtime-evidence verification inside LabTrust-Gym. It is not a clinical validation, production medical certification, or guarantee about a real hospital laboratory." + ], + "reproduce_commands": [ + "labtrust run-demo qc-release", + "labtrust export-pcs --run runs/qc-release --out science_claim_bundle.pending.json" + ], + "runtime_receipt": { + "created_at": "2026-05-01T11:15:00Z", + "events_hash": "sha256:events-qc-release", + "id": "runtime-receipt-qc-release", + "payload": { + "release_gate": "passed", + "run_id": "runs/qc-release", + "steps_observed": 42 + }, + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "schema_version": "RuntimeReceipt.v0", + "signature_or_digest": "sha256:runtime-receipt-001", + "source_commit": "labtrust-qc-release-demo", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "status": "RuntimeChecked", + "summary": "Observed workflow steps, instrument events, and release gate transitions for qc-release.", + "trace_hash": "sha256:trace-json-qc-release" + }, + "schema_version": "PcsClaimReadModel.v0", + "source_repositories": [ + { + "source_commit": "labtrust-qc-release-demo", + "source_repo": "https://github.com/fraware/LabTrust-Gym" + }, + { + "source_commit": "certifyedge-demo", + "source_repo": "https://github.com/fraware/CertifyEdge" + }, + { + "source_commit": "abc123def456", + "source_repo": "https://github.com/SentinelOps-CI/provability-fabric" + } + ], + "trace_certificate": { + "created_at": "2026-05-01T11:20:00Z", + "id": "trace-certificate-qc-release", + "payload": { + "certificate_status": "valid", + "spec": "templates/hospital_lab/qc_release.stl" + }, + "policy_hash": "sha256:temporal-policy-qc-release", + "producer": "CertifyEdge", + "producer_version": "0.1.0", + "schema_version": "TraceCertificate.v0", + "signature_or_digest": "sha256:trace-certificate-001", + "source_commit": "certifyedge-demo", + "source_repo": "https://github.com/fraware/CertifyEdge", + "spec_hash": "sha256:qc-release-stl", + "status": "CertificateChecked", + "summary": "Temporal certificate attests trace.json satisfies qc_release.stl." + }, + "verification_result": { + "checks": [ + { + "detail": "RuntimeReceipt.v0 digest matches bundle linkage.", + "guarantee_type": "runtime_observed", + "id": "check-runtime-receipt", + "name": "Runtime receipt hash chain", + "outcome": "pass" + }, + { + "detail": "TraceCertificate.v0 status CertificateChecked.", + "guarantee_type": "certificate_checked", + "id": "check-trace-certificate", + "name": "Trace certificate temporal policy", + "outcome": "pass" + }, + { + "detail": "Provability Fabric signing check passed.", + "guarantee_type": "formally_checked", + "id": "check-bundle-signature", + "name": "Bundle signature", + "outcome": "pass" + } + ], + "created_at": "2026-05-01T12:00:00Z", + "id": "verification-result-labtrust-qc-release", + "overall_outcome": "pass", + "producer": "provability-fabric", + "producer_version": "0.1.0", + "schema_version": "VerificationResult.v0", + "signature_or_digest": "sha256:verification-result-001", + "source_commit": "abc123def456", + "source_repo": "https://github.com/SentinelOps-CI/provability-fabric", + "status": "ProofChecked" + }, + "verify_commands": [ + "pf verify science-claim signed_science_claim_bundle.json", + "just pcs-validate-bundle BUNDLE=signed_science_claim_bundle.json" + ] + } + }, + "schema_version": "PcsPortalExport.v0" +} diff --git a/portal/app/layout.tsx b/portal/app/layout.tsx index 21938c3..1adc66e 100644 --- a/portal/app/layout.tsx +++ b/portal/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next"; +import { SiteNav } from "@/components/SiteNav"; + export const metadata: Metadata = { title: "Scientific Memory", description: "Buildable, machine-checkable scientific knowledge.", @@ -13,6 +15,7 @@ export default function RootLayout({ return ( + {children} diff --git a/portal/app/page.tsx b/portal/app/page.tsx index 85c94b3..e947cf8 100644 --- a/portal/app/page.tsx +++ b/portal/app/page.tsx @@ -11,18 +11,6 @@ export default async function HomePage() { Buildable, machine-checkable scientific knowledge.

- -

Papers

    diff --git a/portal/app/pcs/claims/[claimId]/page.tsx b/portal/app/pcs/claims/[claimId]/page.tsx new file mode 100644 index 0000000..f68a04a --- /dev/null +++ b/portal/app/pcs/claims/[claimId]/page.tsx @@ -0,0 +1,34 @@ +import { PcsClaimPage } from "@/components/pcs/PcsClaimPage"; +import { getAllPcsClaimIds, getPcsClaimById } from "@/lib/pcsData"; + +export async function generateStaticParams() { + const ids = await getAllPcsClaimIds(); + return ids.map((claimId) => ({ claimId })); +} + +export const dynamicParams = false; + +export default async function PcsClaimRoute({ + params, +}: { + params: Promise<{ claimId: string }>; +}) { + const { claimId } = await params; + const model = await getPcsClaimById(claimId); + + if (!model) { + return ( +
    +

    PCS claim not found

    +

    + No imported claim with id “{claimId}”. Run{" "} + just pcs-import-bundle{" "} + then{" "} + just pcs-render-claim. +

    +
    + ); + } + + return ; +} diff --git a/portal/app/pcs/page.tsx b/portal/app/pcs/page.tsx new file mode 100644 index 0000000..aff0043 --- /dev/null +++ b/portal/app/pcs/page.tsx @@ -0,0 +1,53 @@ +import Link from "next/link"; + +import { getAllPcsClaimIds, getPcsClaimById } from "@/lib/pcsData"; + +export default async function PcsClaimsIndexPage() { + const claimIds = await getAllPcsClaimIds(); + + return ( +
    +

    PCS claims

    +

    + Proof-carrying science artifacts imported from signed LabTrust bundles. +

    + + {claimIds.length === 0 ? ( +

    + No claims imported yet. Run{" "} + just pcs-import-bundle{" "} + then{" "} + just pcs-render-claim. +

    + ) : ( +
      + {await Promise.all( + claimIds.map(async (claimId) => { + const model = await getPcsClaimById(claimId); + return ( +
    • + + {claimId} + + {model?.claim?.text != null && ( +

      + {model.claim.text} +

      + )} + {model?.claim?.status != null && ( +

      + Status: {model.claim.status} +

      + )} +
    • + ); + }), + )} +
    + )} +
    + ); +} diff --git a/portal/components/SiteNav.tsx b/portal/components/SiteNav.tsx new file mode 100644 index 0000000..c0db075 --- /dev/null +++ b/portal/components/SiteNav.tsx @@ -0,0 +1,26 @@ +import Link from "next/link"; + +const links = [ + { href: "/", label: "Home" }, + { href: "/search", label: "Search" }, + { href: "/dashboard", label: "Dashboard" }, + { href: "/diff", label: "Diff" }, + { href: "/pcs", label: "PCS claims" }, +] as const; + +export function SiteNav() { + return ( + + ); +} diff --git a/portal/components/pcs/ArtifactHashTable.tsx b/portal/components/pcs/ArtifactHashTable.tsx new file mode 100644 index 0000000..41f250f --- /dev/null +++ b/portal/components/pcs/ArtifactHashTable.tsx @@ -0,0 +1,46 @@ +import type { PcsHashRow } from "@/lib/pcsTypes"; + +interface ArtifactHashTableProps { + hashes: PcsHashRow[]; +} + +export function ArtifactHashTable({ hashes }: ArtifactHashTableProps) { + return ( +
    +

    Artifact Hashes

    + {hashes.length === 0 ? ( +

    No hashes recorded.

    + ) : ( +
    + + + + + + + + + + + {hashes.map((row) => ( + + + + + + + ))} + +
    NameDigestAlgorithmSource artifact
    {row.name} + {row.digest} + {row.algorithm ?? "sha256"} + {row.source_artifact ?? "—"} +
    +
    + )} +
    + ); +} diff --git a/portal/components/pcs/AssumptionSetView.tsx b/portal/components/pcs/AssumptionSetView.tsx new file mode 100644 index 0000000..81805f5 --- /dev/null +++ b/portal/components/pcs/AssumptionSetView.tsx @@ -0,0 +1,41 @@ +import type { PcsAssumption, PcsNamedArtifact } from "@/lib/pcsTypes"; + +interface AssumptionSetViewProps { + assumptionSet: PcsNamedArtifact & { assumptions?: PcsAssumption[] }; +} + +export function AssumptionSetView({ assumptionSet }: AssumptionSetViewProps) { + const assumptions = assumptionSet.assumptions ?? []; + return ( +
    +

    Assumptions

    + {assumptionSet.status != null && ( +

    + Set status:{" "} + + {String(assumptionSet.status)} + +

    + )} +
      + {assumptions.map((a) => ( +
    • + {a.id} + {a.kind != null && ( + ({a.kind}) + )} + {a.status != null && ( + + {a.status} + + )} +

      {a.text}

      +
    • + ))} +
    +
    + ); +} diff --git a/portal/components/pcs/ClaimArtifactView.tsx b/portal/components/pcs/ClaimArtifactView.tsx new file mode 100644 index 0000000..6e59289 --- /dev/null +++ b/portal/components/pcs/ClaimArtifactView.tsx @@ -0,0 +1,48 @@ +import type { PcsClaimSection } from "@/lib/pcsTypes"; + +const GUARANTEE_LABELS: Record = { + formally_checked: "Formally checked", + certificate_checked: "Certificate checked", + runtime_observed: "Runtime observed", + empirically_measured: "Empirically measured", + human_reviewed: "Human reviewed", + unchecked_advisory: "Unchecked advisory", +}; + +interface ClaimArtifactViewProps { + claim: PcsClaimSection; +} + +export function ClaimArtifactView({ claim }: ClaimArtifactViewProps) { + const guarantees = claim.guarantee_types ?? {}; + return ( +
    +

    Claim

    +

    {claim.text}

    +

    + Status:{" "} + + {claim.status} + +

    +
    +

    Guarantee separation

    +
      + {Object.entries(GUARANTEE_LABELS).map(([key, label]) => ( +
    • + + {label}: {guarantees[key] ? "yes" : "no"} + +
    • + ))} +
    +
    +
    + ); +} diff --git a/portal/components/pcs/LimitationNotice.tsx b/portal/components/pcs/LimitationNotice.tsx new file mode 100644 index 0000000..73c571f --- /dev/null +++ b/portal/components/pcs/LimitationNotice.tsx @@ -0,0 +1,25 @@ +interface LimitationNoticeProps { + notice: string; + additional?: string[]; +} + +export function LimitationNotice({ notice, additional }: LimitationNoticeProps) { + const extras = (additional ?? []).filter((line) => line.trim() && line !== notice); + return ( + + ); +} diff --git a/portal/components/pcs/PcsClaimPage.tsx b/portal/components/pcs/PcsClaimPage.tsx new file mode 100644 index 0000000..5701f71 --- /dev/null +++ b/portal/components/pcs/PcsClaimPage.tsx @@ -0,0 +1,45 @@ +import type { PcsClaimReadModel } from "@/lib/pcsTypes"; + +import { ArtifactHashTable } from "./ArtifactHashTable"; +import { AssumptionSetView } from "./AssumptionSetView"; +import { ClaimArtifactView } from "./ClaimArtifactView"; +import { LimitationNotice } from "./LimitationNotice"; +import { ReplayCommand } from "./ReplayCommand"; +import { RuntimeReceiptView } from "./RuntimeReceiptView"; +import { SourceRepositories } from "./SourceRepositories"; +import { TraceCertificateView } from "./TraceCertificateView"; +import { VerificationResultView } from "./VerificationResultView"; + +interface PcsClaimPageProps { + model: PcsClaimReadModel; +} + +export function PcsClaimPage({ model }: PcsClaimPageProps) { + const extraLimitations = model.limitations.filter( + (l) => l !== model.limitation_notice, + ); + return ( +
    +
    +

    LabTrust PCS claim

    +

    {model.claim_id}

    +

    + Bundle digest: {model.bundle_signature_or_digest} +

    +
    + + + + + + + + + + +
    + ); +} diff --git a/portal/components/pcs/ReplayCommand.tsx b/portal/components/pcs/ReplayCommand.tsx new file mode 100644 index 0000000..1cd498a --- /dev/null +++ b/portal/components/pcs/ReplayCommand.tsx @@ -0,0 +1,46 @@ +interface ReplayCommandProps { + reproduceCommands: string[]; + verifyCommands: string[]; +} + +export function ReplayCommand({ + reproduceCommands, + verifyCommands, +}: ReplayCommandProps) { + return ( +
    +

    Reproduce / Verify

    + {reproduceCommands.length > 0 && ( +
    +

    Reproduce

    +
      + {reproduceCommands.map((cmd) => ( +
    • +
      +                  {cmd}
      +                
      +
    • + ))} +
    +
    + )} + {verifyCommands.length > 0 && ( +
    +

    Verify externally

    +
      + {verifyCommands.map((cmd) => ( +
    • +
      +                  {cmd}
      +                
      +
    • + ))} +
    +
    + )} + {reproduceCommands.length === 0 && verifyCommands.length === 0 && ( +

    No reproduce or verify commands recorded.

    + )} +
    + ); +} diff --git a/portal/components/pcs/RuntimeReceiptView.tsx b/portal/components/pcs/RuntimeReceiptView.tsx new file mode 100644 index 0000000..f3f2418 --- /dev/null +++ b/portal/components/pcs/RuntimeReceiptView.tsx @@ -0,0 +1,30 @@ +import type { PcsNamedArtifact } from "@/lib/pcsTypes"; + +interface RuntimeReceiptViewProps { + receipt: PcsNamedArtifact; +} + +export function RuntimeReceiptView({ receipt }: RuntimeReceiptViewProps) { + return ( +
    +

    Runtime Evidence

    +

    + Status:{" "} + + {String(receipt.status ?? "unknown")} + +

    + {receipt.summary != null && ( +

    {String(receipt.summary)}

    + )} + {receipt.payload != null && ( +
    +          {JSON.stringify(receipt.payload, null, 2)}
    +        
    + )} +
    + ); +} diff --git a/portal/components/pcs/SourceRepositories.tsx b/portal/components/pcs/SourceRepositories.tsx new file mode 100644 index 0000000..e00f3ee --- /dev/null +++ b/portal/components/pcs/SourceRepositories.tsx @@ -0,0 +1,38 @@ +interface SourceRepositoriesProps { + sources: { source_repo: string; source_commit: string }[]; +} + +export function SourceRepositories({ sources }: SourceRepositoriesProps) { + return ( +
    +

    Source Repositories

    + {sources.length === 0 ? ( +

    No source metadata recorded.

    + ) : ( +
      + {sources.map((s) => ( +
    • +

      + Repository:{" "} + + {s.source_repo} + +

      +

      + Commit: {s.source_commit} +

      +
    • + ))} +
    + )} +
    + ); +} diff --git a/portal/components/pcs/TraceCertificateView.tsx b/portal/components/pcs/TraceCertificateView.tsx new file mode 100644 index 0000000..3a739f4 --- /dev/null +++ b/portal/components/pcs/TraceCertificateView.tsx @@ -0,0 +1,30 @@ +import type { PcsNamedArtifact } from "@/lib/pcsTypes"; + +interface TraceCertificateViewProps { + certificate: PcsNamedArtifact; +} + +export function TraceCertificateView({ certificate }: TraceCertificateViewProps) { + return ( +
    +

    Temporal Certificate

    +

    + Status:{" "} + + {String(certificate.status ?? "unknown")} + +

    + {certificate.summary != null && ( +

    {String(certificate.summary)}

    + )} + {certificate.payload != null && ( +
    +          {JSON.stringify(certificate.payload, null, 2)}
    +        
    + )} +
    + ); +} diff --git a/portal/components/pcs/VerificationResultView.tsx b/portal/components/pcs/VerificationResultView.tsx new file mode 100644 index 0000000..8d35536 --- /dev/null +++ b/portal/components/pcs/VerificationResultView.tsx @@ -0,0 +1,61 @@ +import type { PcsVerificationResult } from "@/lib/pcsTypes"; + +interface VerificationResultViewProps { + result: PcsVerificationResult | null | undefined; +} + +export function VerificationResultView({ result }: VerificationResultViewProps) { + if (!result) { + return ( +
    +

    Verification Result

    +

    + No VerificationResult was bundled. Checks from Provability Fabric are not shown. +

    +
    + ); + } + + const checks = result.checks ?? []; + return ( +
    +

    Verification Result

    +

    + Status:{" "} + + {String(result.status ?? "unknown")} + + {result.overall_outcome != null && ( + + Overall:{" "} + + {result.overall_outcome} + + + )} +

    +
      + {checks.map((c) => ( +
    • +
      + {c.name} + + {c.outcome} + + {c.guarantee_type != null && ( + {c.guarantee_type} + )} +
      + {c.detail != null &&

      {c.detail}

      } +
    • + ))} +
    +
    + ); +} diff --git a/portal/lib/pcsData.ts b/portal/lib/pcsData.ts new file mode 100644 index 0000000..2fc4e42 --- /dev/null +++ b/portal/lib/pcsData.ts @@ -0,0 +1,39 @@ +import { promises as fs } from "fs"; +import path from "path"; + +import type { PcsClaimReadModel, PcsPortalExport } from "./pcsTypes"; + +const ROOT = process.cwd(); +const PCS_EXPORT = path.join(ROOT, ".generated", "pcs-export.json"); +const CORPUS_PCS = path.join(ROOT, "..", "corpus", "pcs", "claims"); + +async function readPcsExport(): Promise { + const raw = await fs.readFile(PCS_EXPORT, "utf8").catch(() => null); + if (!raw) return null; + return JSON.parse(raw) as PcsPortalExport; +} + +async function readClaimFromCorpus(claimId: string): Promise { + const p = path.join(CORPUS_PCS, claimId, "read_model.json"); + const raw = await fs.readFile(p, "utf8").catch(() => null); + if (!raw) return null; + return JSON.parse(raw) as PcsClaimReadModel; +} + +export async function getAllPcsClaimIds(): Promise { + const exported = await readPcsExport(); + if (exported?.claim_ids?.length) { + return exported.claim_ids; + } + const entries = await fs.readdir(CORPUS_PCS, { withFileTypes: true }).catch(() => []); + return entries.filter((e) => e.isDirectory()).map((e) => e.name); +} + +export async function getPcsClaimById( + claimId: string, +): Promise { + const exported = await readPcsExport(); + const fromExport = exported?.claims?.[claimId]; + if (fromExport) return fromExport; + return readClaimFromCorpus(claimId); +} diff --git a/portal/lib/pcsTypes.ts b/portal/lib/pcsTypes.ts new file mode 100644 index 0000000..0a3de11 --- /dev/null +++ b/portal/lib/pcsTypes.ts @@ -0,0 +1,77 @@ +/** PCS portal read model (from pipeline pcs_import/artifact_normalizer). */ + +export type PcsGuaranteeTypes = Record; + +export type PcsClaimSection = { + id: string; + text: string; + status: string; + signature_or_digest: string; + guarantee_types: PcsGuaranteeTypes; +}; + +export type PcsAssumption = { + id: string; + text: string; + kind?: string; + status?: string; +}; + +export type PcsNamedArtifact = { + id: string; + schema_version?: string; + status?: string; + signature_or_digest?: string; + source_repo?: string; + source_commit?: string; + summary?: string; + payload?: Record; + [key: string]: unknown; +}; + +export type PcsVerificationCheck = { + id: string; + name: string; + outcome: string; + detail?: string; + guarantee_type?: string; +}; + +export type PcsVerificationResult = { + status?: string; + overall_outcome?: string; + signature_or_digest?: string; + source_repo?: string; + source_commit?: string; + checks?: PcsVerificationCheck[]; +}; + +export type PcsHashRow = { + name: string; + digest: string; + algorithm?: string; + source_artifact?: string; +}; + +export type PcsClaimReadModel = { + schema_version: string; + claim_id: string; + claim: PcsClaimSection; + assumption_set: PcsNamedArtifact & { assumptions?: PcsAssumption[] }; + runtime_receipt: PcsNamedArtifact; + trace_certificate: PcsNamedArtifact; + verification_result?: PcsVerificationResult | null; + artifact_hashes: PcsHashRow[]; + source_repositories: { source_repo: string; source_commit: string }[]; + reproduce_commands: string[]; + verify_commands: string[]; + limitations: string[]; + limitation_notice: string; + bundle_signature_or_digest: string; +}; + +export type PcsPortalExport = { + schema_version: string; + claim_ids: string[]; + claims: Record; +}; diff --git a/pyproject.toml b/pyproject.toml index 5866265..18bdc61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,4 +12,4 @@ line-length = 100 target-version = "py311" [tool.pytest.ini_options] -testpaths = ["pipeline/tests", "kernels/adsorption/tests"] +testpaths = ["pipeline/tests", "kernels/adsorption/tests", "tests/pcs"] diff --git a/schemas/pcs/artifact_base.schema.json b/schemas/pcs/artifact_base.schema.json new file mode 100644 index 0000000..032296b --- /dev/null +++ b/schemas/pcs/artifact_base.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://scientific-memory.org/schemas/pcs/artifact_base.schema.json", + "title": "PCS Artifact Base (v0.1)", + "type": "object", + "additionalProperties": true, + "required": [ + "schema_version", + "created_at", + "producer", + "producer_version", + "source_repo", + "source_commit", + "status", + "signature_or_digest" + ], + "properties": { + "schema_version": { "type": "string", "minLength": 1 }, + "created_at": { "type": "string", "minLength": 1 }, + "producer": { "type": "string", "minLength": 1 }, + "producer_version": { "type": "string", "minLength": 1 }, + "source_repo": { "type": "string", "minLength": 1 }, + "source_commit": { "type": "string", "minLength": 1 }, + "status": { + "type": "string", + "enum": [ + "Draft", + "Extracted", + "HumanReviewed", + "Formalized", + "ProofPending", + "ProofChecked", + "CertificatePending", + "CertificateChecked", + "RuntimeObserved", + "RuntimeChecked", + "Rejected", + "EmpiricalOnly", + "Deprecated", + "Stale" + ] + }, + "signature_or_digest": { "type": "string", "minLength": 1 }, + "input_hashes": { + "type": "array", + "items": { "$ref": "#/$defs/hashEntry" } + }, + "output_hashes": { + "type": "array", + "items": { "$ref": "#/$defs/hashEntry" } + }, + "dependencies": { + "type": "array", + "items": { "type": "string" } + }, + "trace_hash": { "type": "string" }, + "policy_hash": { "type": "string" }, + "events_hash": { "type": "string" }, + "spec_hash": { "type": "string" } + }, + "$defs": { + "hashEntry": { + "type": "object", + "required": ["name", "digest"], + "properties": { + "name": { "type": "string" }, + "digest": { "type": "string" }, + "algorithm": { "type": "string" } + }, + "additionalProperties": true + } + } +} diff --git a/schemas/pcs/science_claim_bundle.schema.json b/schemas/pcs/science_claim_bundle.schema.json new file mode 100644 index 0000000..05dbce0 --- /dev/null +++ b/schemas/pcs/science_claim_bundle.schema.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://scientific-memory.org/schemas/pcs/science_claim_bundle.schema.json", + "title": "ScienceClaimBundle.v0", + "type": "object", + "additionalProperties": true, + "required": [ + "schema_version", + "created_at", + "producer", + "producer_version", + "source_repo", + "source_commit", + "status", + "signature_or_digest", + "claim", + "assumption_set", + "runtime_receipt", + "trace_certificate" + ], + "properties": { + "schema_version": { "const": "ScienceClaimBundle.v0" }, + "claim": { "$ref": "#/$defs/claimArtifact" }, + "assumption_set": { "$ref": "#/$defs/assumptionSet" }, + "runtime_receipt": { "$ref": "#/$defs/namedArtifact" }, + "trace_certificate": { "$ref": "#/$defs/namedArtifact" }, + "evidence_bundle": { "$ref": "#/$defs/namedArtifact" }, + "verification_result": { "$ref": "#/$defs/namedArtifact" }, + "limitations": { + "type": "array", + "items": { "type": "string" } + }, + "reproduce_commands": { + "type": "array", + "items": { "type": "string" } + }, + "verify_commands": { + "type": "array", + "items": { "type": "string" } + } + }, + "$defs": { + "artifactBase": { + "$ref": "https://scientific-memory.org/schemas/pcs/artifact_base.schema.json" + }, + "claimArtifact": { + "allOf": [ + { "$ref": "#/$defs/artifactBase" }, + { + "type": "object", + "required": ["id", "claim_text", "guarantee_types"], + "properties": { + "schema_version": { "const": "ClaimArtifact.v0" }, + "id": { "type": "string", "minLength": 1 }, + "claim_text": { "type": "string", "minLength": 1 }, + "guarantee_types": { + "type": "object", + "additionalProperties": { "type": "boolean" } + } + } + } + ] + }, + "assumptionSet": { + "allOf": [ + { "$ref": "#/$defs/artifactBase" }, + { + "type": "object", + "required": ["id", "assumptions"], + "properties": { + "schema_version": { "const": "AssumptionSet.v0" }, + "id": { "type": "string", "minLength": 1 }, + "assumptions": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "text"], + "properties": { + "id": { "type": "string" }, + "text": { "type": "string" }, + "kind": { "type": "string" }, + "status": { "type": "string" } + }, + "additionalProperties": true + } + } + } + } + ] + }, + "namedArtifact": { + "allOf": [ + { "$ref": "#/$defs/artifactBase" }, + { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "payload": { "type": "object" }, + "summary": { "type": "string" } + } + } + ] + } + } +} diff --git a/schemas/pcs/signed_science_claim_bundle.schema.json b/schemas/pcs/signed_science_claim_bundle.schema.json new file mode 100644 index 0000000..f6c22ed --- /dev/null +++ b/schemas/pcs/signed_science_claim_bundle.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://scientific-memory.org/schemas/pcs/signed_science_claim_bundle.schema.json", + "title": "SignedScienceClaimBundle (LabTrust v0.1)", + "type": "object", + "additionalProperties": true, + "required": ["schema_version", "science_claim_bundle", "signature_or_digest"], + "properties": { + "schema_version": { + "type": "string", + "pattern": "^SignedScienceClaimBundle\\.v0$" + }, + "science_claim_bundle": { + "$ref": "https://scientific-memory.org/schemas/pcs/science_claim_bundle.schema.json" + }, + "verification_result": { + "$ref": "https://scientific-memory.org/schemas/pcs/verification_result.schema.json" + }, + "signature_or_digest": { "type": "string", "minLength": 1 }, + "reproduce_commands": { + "type": "array", + "items": { "type": "string" } + }, + "verify_commands": { + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/schemas/pcs/verification_result.schema.json b/schemas/pcs/verification_result.schema.json new file mode 100644 index 0000000..50fde71 --- /dev/null +++ b/schemas/pcs/verification_result.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://scientific-memory.org/schemas/pcs/verification_result.schema.json", + "title": "VerificationResult.v0", + "type": "object", + "additionalProperties": true, + "required": [ + "schema_version", + "created_at", + "producer", + "producer_version", + "source_repo", + "source_commit", + "status", + "signature_or_digest", + "checks" + ], + "properties": { + "schema_version": { "const": "VerificationResult.v0" }, + "checks": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name", "outcome"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "outcome": { + "type": "string", + "enum": ["pass", "fail", "skip", "warn"] + }, + "detail": { "type": "string" }, + "guarantee_type": { "type": "string" } + }, + "additionalProperties": true + } + }, + "overall_outcome": { + "type": "string", + "enum": ["pass", "fail", "partial"] + } + }, + "allOf": [ + { + "$ref": "https://scientific-memory.org/schemas/pcs/artifact_base.schema.json" + } + ] +} diff --git a/tests/pcs/conftest.py b/tests/pcs/conftest.py new file mode 100644 index 0000000..215e1c4 --- /dev/null +++ b/tests/pcs/conftest.py @@ -0,0 +1,8 @@ +"""Ensure pipeline package is importable when running PCS tests from repo root.""" + +import sys +from pathlib import Path + +_PIPELINE_SRC = Path(__file__).resolve().parents[2] / "pipeline" / "src" +if str(_PIPELINE_SRC) not in sys.path: + sys.path.insert(0, str(_PIPELINE_SRC)) diff --git a/tests/pcs/fixtures/invalid_missing_signature.json b/tests/pcs/fixtures/invalid_missing_signature.json new file mode 100644 index 0000000..dfad584 --- /dev/null +++ b/tests/pcs/fixtures/invalid_missing_signature.json @@ -0,0 +1,65 @@ +{ + "schema_version": "SignedScienceClaimBundle.v0", + "science_claim_bundle": { + "schema_version": "ScienceClaimBundle.v0", + "created_at": "2026-05-01T11:30:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "bad", + "status": "Draft", + "signature_or_digest": "sha256:bundle", + "claim": { + "schema_version": "ClaimArtifact.v0", + "id": "bad-claim", + "created_at": "2026-05-01T11:00:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "bad", + "status": "Draft", + "signature_or_digest": "sha256:claim", + "claim_text": "Incomplete bundle.", + "guarantee_types": {} + }, + "assumption_set": { + "schema_version": "AssumptionSet.v0", + "id": "assumptions", + "created_at": "2026-05-01T11:00:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "bad", + "status": "Draft", + "signature_or_digest": "sha256:assumptions", + "assumptions": [ + { + "id": "a1", + "text": "One assumption." + } + ] + }, + "runtime_receipt": { + "schema_version": "RuntimeReceipt.v0", + "id": "receipt", + "created_at": "2026-05-01T11:00:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "bad", + "status": "RuntimeObserved", + "signature_or_digest": "sha256:receipt" + }, + "trace_certificate": { + "schema_version": "TraceCertificate.v0", + "id": "cert", + "created_at": "2026-05-01T11:00:00Z", + "producer": "CertifyEdge", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/CertifyEdge", + "source_commit": "bad", + "status": "CertificatePending", + "signature_or_digest": "sha256:cert" + } + } +} diff --git a/tests/pcs/fixtures/missing_assumptions.json b/tests/pcs/fixtures/missing_assumptions.json new file mode 100644 index 0000000..b39a98e --- /dev/null +++ b/tests/pcs/fixtures/missing_assumptions.json @@ -0,0 +1,61 @@ +{ + "schema_version": "SignedScienceClaimBundle.v0", + "signature_or_digest": "sha256:missing-assumptions", + "science_claim_bundle": { + "schema_version": "ScienceClaimBundle.v0", + "created_at": "2026-05-01T11:30:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "bad", + "status": "Draft", + "signature_or_digest": "sha256:bundle", + "claim": { + "schema_version": "ClaimArtifact.v0", + "id": "claim-no-assumptions", + "created_at": "2026-05-01T11:00:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "bad", + "status": "Draft", + "signature_or_digest": "sha256:claim", + "claim_text": "Claim without assumptions.", + "guarantee_types": {} + }, + "assumption_set": { + "schema_version": "AssumptionSet.v0", + "id": "empty-assumptions", + "created_at": "2026-05-01T11:00:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "bad", + "status": "Draft", + "signature_or_digest": "sha256:assumptions", + "assumptions": [] + }, + "runtime_receipt": { + "schema_version": "RuntimeReceipt.v0", + "id": "receipt", + "created_at": "2026-05-01T11:00:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "bad", + "status": "RuntimeObserved", + "signature_or_digest": "sha256:receipt" + }, + "trace_certificate": { + "schema_version": "TraceCertificate.v0", + "id": "cert", + "created_at": "2026-05-01T11:00:00Z", + "producer": "CertifyEdge", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/CertifyEdge", + "source_commit": "bad", + "status": "CertificateChecked", + "signature_or_digest": "sha256:cert" + } + } +} diff --git a/tests/pcs/fixtures/missing_verification_result.json b/tests/pcs/fixtures/missing_verification_result.json new file mode 100644 index 0000000..17b2618 --- /dev/null +++ b/tests/pcs/fixtures/missing_verification_result.json @@ -0,0 +1,70 @@ +{ + "schema_version": "SignedScienceClaimBundle.v0", + "signature_or_digest": "sha256:no-verification-result", + "science_claim_bundle": { + "schema_version": "ScienceClaimBundle.v0", + "created_at": "2026-05-01T11:30:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "warn-demo", + "status": "RuntimeChecked", + "signature_or_digest": "sha256:bundle", + "claim": { + "schema_version": "ClaimArtifact.v0", + "id": "claim-missing-vr", + "created_at": "2026-05-01T11:00:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "warn-demo", + "status": "RuntimeChecked", + "signature_or_digest": "sha256:claim", + "claim_text": "Bundle without verification result.", + "guarantee_types": { + "runtime_observed": true, + "certificate_checked": false + } + }, + "assumption_set": { + "schema_version": "AssumptionSet.v0", + "id": "assumptions", + "created_at": "2026-05-01T11:00:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "warn-demo", + "status": "RuntimeObserved", + "signature_or_digest": "sha256:assumptions", + "assumptions": [ + { + "id": "a1", + "text": "Simulation only." + } + ] + }, + "runtime_receipt": { + "schema_version": "RuntimeReceipt.v0", + "id": "receipt", + "created_at": "2026-05-01T11:00:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "warn-demo", + "status": "RuntimeChecked", + "signature_or_digest": "sha256:receipt", + "trace_hash": "sha256:trace" + }, + "trace_certificate": { + "schema_version": "TraceCertificate.v0", + "id": "cert", + "created_at": "2026-05-01T11:00:00Z", + "producer": "CertifyEdge", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/CertifyEdge", + "source_commit": "warn-demo", + "status": "CertificatePending", + "signature_or_digest": "sha256:cert" + } + } +} diff --git a/tests/pcs/fixtures/valid_signed_science_claim_bundle.json b/tests/pcs/fixtures/valid_signed_science_claim_bundle.json new file mode 100644 index 0000000..440a73d --- /dev/null +++ b/tests/pcs/fixtures/valid_signed_science_claim_bundle.json @@ -0,0 +1,146 @@ +{ + "schema_version": "SignedScienceClaimBundle.v0", + "signature_or_digest": "sha256:signed-bundle-labtrust-demo-001", + "reproduce_commands": [ + "labtrust run-demo qc-release", + "labtrust export-pcs --run runs/qc-release --out science_claim_bundle.pending.json" + ], + "verify_commands": [ + "pf verify science-claim signed_science_claim_bundle.json", + "just pcs-validate-bundle BUNDLE=signed_science_claim_bundle.json" + ], + "verification_result": { + "schema_version": "VerificationResult.v0", + "id": "verification-result-labtrust-qc-release", + "created_at": "2026-05-01T12:00:00Z", + "producer": "provability-fabric", + "producer_version": "0.1.0", + "source_repo": "https://github.com/SentinelOps-CI/provability-fabric", + "source_commit": "abc123def456", + "status": "ProofChecked", + "signature_or_digest": "sha256:verification-result-001", + "overall_outcome": "pass", + "checks": [ + { + "id": "check-runtime-receipt", + "name": "Runtime receipt hash chain", + "outcome": "pass", + "guarantee_type": "runtime_observed", + "detail": "RuntimeReceipt.v0 digest matches bundle linkage." + }, + { + "id": "check-trace-certificate", + "name": "Trace certificate temporal policy", + "outcome": "pass", + "guarantee_type": "certificate_checked", + "detail": "TraceCertificate.v0 status CertificateChecked." + }, + { + "id": "check-bundle-signature", + "name": "Bundle signature", + "outcome": "pass", + "guarantee_type": "formally_checked", + "detail": "Provability Fabric signing check passed." + } + ] + }, + "science_claim_bundle": { + "schema_version": "ScienceClaimBundle.v0", + "created_at": "2026-05-01T11:30:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "labtrust-qc-release-demo", + "status": "RuntimeChecked", + "signature_or_digest": "sha256:science-claim-bundle-001", + "claim": { + "schema_version": "ClaimArtifact.v0", + "id": "labtrust-qc-release-claim-001", + "created_at": "2026-05-01T11:00:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "labtrust-qc-release-demo", + "status": "RuntimeChecked", + "signature_or_digest": "sha256:claim-artifact-001", + "claim_text": "The qc-release workflow run observed protocol-compliant specimen handling and release gating in the LabTrust-Gym hospital-lab simulation.", + "guarantee_types": { + "formally_checked": true, + "certificate_checked": true, + "runtime_observed": true, + "empirically_measured": false, + "human_reviewed": false, + "unchecked_advisory": false + }, + "output_hashes": [ + { + "name": "claim_digest", + "digest": "sha256:claim-artifact-001", + "algorithm": "sha256" + } + ] + }, + "assumption_set": { + "schema_version": "AssumptionSet.v0", + "id": "labtrust-qc-release-assumptions", + "created_at": "2026-05-01T11:00:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "labtrust-qc-release-demo", + "status": "RuntimeObserved", + "signature_or_digest": "sha256:assumption-set-001", + "assumptions": [ + { + "id": "assume-sim-lab", + "text": "The environment is the LabTrust-Gym simulated hospital laboratory, not a production clinical site.", + "kind": "simulation_scope", + "status": "RuntimeObserved" + }, + { + "id": "assume-qc-policy", + "text": "QC release policy template hospital_lab/qc_release.stl governs acceptable release transitions.", + "kind": "policy", + "status": "CertificateChecked" + } + ] + }, + "runtime_receipt": { + "schema_version": "RuntimeReceipt.v0", + "id": "runtime-receipt-qc-release", + "created_at": "2026-05-01T11:15:00Z", + "producer": "LabTrust-Gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "labtrust-qc-release-demo", + "status": "RuntimeChecked", + "signature_or_digest": "sha256:runtime-receipt-001", + "summary": "Observed workflow steps, instrument events, and release gate transitions for qc-release.", + "trace_hash": "sha256:trace-json-qc-release", + "events_hash": "sha256:events-qc-release", + "payload": { + "run_id": "runs/qc-release", + "steps_observed": 42, + "release_gate": "passed" + } + }, + "trace_certificate": { + "schema_version": "TraceCertificate.v0", + "id": "trace-certificate-qc-release", + "created_at": "2026-05-01T11:20:00Z", + "producer": "CertifyEdge", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/CertifyEdge", + "source_commit": "certifyedge-demo", + "status": "CertificateChecked", + "signature_or_digest": "sha256:trace-certificate-001", + "spec_hash": "sha256:qc-release-stl", + "policy_hash": "sha256:temporal-policy-qc-release", + "summary": "Temporal certificate attests trace.json satisfies qc_release.stl.", + "payload": { + "certificate_status": "valid", + "spec": "templates/hospital_lab/qc_release.stl" + } + } + } +} diff --git a/tests/pcs/test_import_labtrust_bundle.py b/tests/pcs/test_import_labtrust_bundle.py new file mode 100644 index 0000000..c4b10b2 --- /dev/null +++ b/tests/pcs/test_import_labtrust_bundle.py @@ -0,0 +1,58 @@ +import json +import shutil +import tempfile +from pathlib import Path + +from sm_pipeline.pcs_import.science_claim_bundle_importer import import_signed_bundle + +FIXTURES = Path(__file__).resolve().parent / "fixtures" +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_valid_bundle_imports() -> None: + bundle_path = FIXTURES / "valid_signed_science_claim_bundle.json" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _copy_schemas(root) + result = import_signed_bundle( + bundle_path, + repo_root=root, + write=True, + ) + assert result.claim_id == "labtrust-qc-release-claim-001" + read_model_path = ( + root / "corpus" / "pcs" / "claims" / result.claim_id / "read_model.json" + ) + assert read_model_path.is_file() + read_model = json.loads(read_model_path.read_text(encoding="utf-8")) + assert read_model["claim"]["text"] + assert read_model["source_repositories"] + assert any( + s["source_repo"] == "https://github.com/fraware/LabTrust-Gym" + for s in read_model["source_repositories"] + ) + + +def test_import_preserves_source_repo_and_commit() -> None: + bundle_path = FIXTURES / "valid_signed_science_claim_bundle.json" + raw = json.loads(bundle_path.read_text(encoding="utf-8")) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _copy_schemas(root) + import_signed_bundle(bundle_path, repo_root=root, write=True) + stored = json.loads( + (root / "corpus" / "pcs" / "claims" / "labtrust-qc-release-claim-001" / "signed_bundle.json").read_text( + encoding="utf-8" + ) + ) + scb = stored["science_claim_bundle"] + assert scb["claim"]["source_repo"] == raw["science_claim_bundle"]["claim"]["source_repo"] + assert scb["claim"]["source_commit"] == raw["science_claim_bundle"]["claim"]["source_commit"] + assert scb["claim"]["signature_or_digest"] == raw["science_claim_bundle"]["claim"]["signature_or_digest"] + + +def _copy_schemas(root: Path) -> None: + dest = root / "schemas" / "pcs" + dest.mkdir(parents=True) + for f in (REPO_ROOT / "schemas" / "pcs").glob("*.json"): + shutil.copy(f, dest / f.name) diff --git a/tests/pcs/test_import_verification_result.py b/tests/pcs/test_import_verification_result.py new file mode 100644 index 0000000..4966318 --- /dev/null +++ b/tests/pcs/test_import_verification_result.py @@ -0,0 +1,61 @@ +import json +import shutil +import tempfile +from pathlib import Path + +from sm_pipeline.pcs_import.science_claim_bundle_importer import import_signed_bundle +from sm_pipeline.pcs_import.verification_result_importer import merge_verification_result +from sm_pipeline.pcs_validate.validator import collect_import_warnings + +FIXTURES = Path(__file__).resolve().parent / "fixtures" +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_missing_verification_result_warning() -> None: + bundle = json.loads( + (FIXTURES / "missing_verification_result.json").read_text(encoding="utf-8") + ) + warnings = collect_import_warnings(bundle) + assert any("VerificationResult is absent" in w for w in warnings) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _copy_schemas(root) + result = import_signed_bundle( + FIXTURES / "missing_verification_result.json", + repo_root=root, + write=True, + ) + assert any("VerificationResult is absent" in w for w in result.warnings) + read_model = json.loads( + (root / "corpus" / "pcs" / "claims" / result.claim_id / "read_model.json").read_text( + encoding="utf-8" + ) + ) + assert read_model.get("verification_result") is None + + +def test_merge_verification_result_preserves_checks() -> None: + bundle = json.loads( + (FIXTURES / "missing_verification_result.json").read_text(encoding="utf-8") + ) + vr = { + "schema_version": "VerificationResult.v0", + "created_at": "2026-05-01T12:00:00Z", + "producer": "provability-fabric", + "producer_version": "0.1.0", + "source_repo": "https://github.com/SentinelOps-CI/provability-fabric", + "source_commit": "abc", + "status": "ProofChecked", + "signature_or_digest": "sha256:vr", + "checks": [{"id": "c1", "name": "test", "outcome": "pass"}], + } + merged = merge_verification_result(bundle, vr) + assert merged["verification_result"]["checks"][0]["id"] == "c1" + + +def _copy_schemas(root: Path) -> None: + dest = root / "schemas" / "pcs" + dest.mkdir(parents=True) + for f in (REPO_ROOT / "schemas" / "pcs").glob("*.json"): + shutil.copy(f, dest / f.name) diff --git a/tests/pcs/test_reject_invalid_bundle.py b/tests/pcs/test_reject_invalid_bundle.py new file mode 100644 index 0000000..7ec566f --- /dev/null +++ b/tests/pcs/test_reject_invalid_bundle.py @@ -0,0 +1,43 @@ +import shutil +import tempfile +from pathlib import Path + +import pytest + +from sm_pipeline.pcs_import.science_claim_bundle_importer import import_signed_bundle +from sm_pipeline.pcs_validate.validator import BundleValidationError, validate_signed_bundle + +FIXTURES = Path(__file__).resolve().parent / "fixtures" +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_invalid_bundle_rejected() -> None: + import json + + bundle = json.loads( + (FIXTURES / "invalid_missing_signature.json").read_text(encoding="utf-8") + ) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _copy_schemas(root) + with pytest.raises(BundleValidationError): + validate_signed_bundle(bundle, repo_root=root, strict=True) + + +def test_missing_assumption_rejected() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _copy_schemas(root) + with pytest.raises(BundleValidationError): + import_signed_bundle( + FIXTURES / "missing_assumptions.json", + repo_root=root, + write=False, + ) + + +def _copy_schemas(root: Path) -> None: + dest = root / "schemas" / "pcs" + dest.mkdir(parents=True) + for f in (REPO_ROOT / "schemas" / "pcs").glob("*.json"): + shutil.copy(f, dest / f.name) diff --git a/tests/pcs/test_render_pcs_claim.py b/tests/pcs/test_render_pcs_claim.py new file mode 100644 index 0000000..ce39abc --- /dev/null +++ b/tests/pcs/test_render_pcs_claim.py @@ -0,0 +1,89 @@ +import json +import shutil +import tempfile +from pathlib import Path + +from sm_pipeline.pcs_import.artifact_normalizer import LIMITATION_NOTICE, normalize_signed_bundle +from sm_pipeline.pcs_import.science_claim_bundle_importer import import_signed_bundle + +FIXTURES = Path(__file__).resolve().parent / "fixtures" +REPO_ROOT = Path(__file__).resolve().parents[2] + +REQUIRED_READ_MODEL_KEYS = ( + "claim", + "assumption_set", + "runtime_receipt", + "trace_certificate", + "artifact_hashes", + "source_repositories", + "reproduce_commands", + "verify_commands", + "limitations", + "limitation_notice", +) + + +def test_portal_read_model_has_all_required_sections() -> None: + bundle = json.loads( + (FIXTURES / "valid_signed_science_claim_bundle.json").read_text(encoding="utf-8") + ) + read_model = normalize_signed_bundle(bundle) + for key in REQUIRED_READ_MODEL_KEYS: + assert key in read_model, f"missing read_model.{key}" + + +def test_limitations_notice_present() -> None: + bundle = json.loads( + (FIXTURES / "valid_signed_science_claim_bundle.json").read_text(encoding="utf-8") + ) + read_model = normalize_signed_bundle(bundle) + assert read_model["limitation_notice"] == LIMITATION_NOTICE + assert LIMITATION_NOTICE in read_model["limitations"] + + +def test_artifact_hashes_displayed() -> None: + bundle = json.loads( + (FIXTURES / "valid_signed_science_claim_bundle.json").read_text(encoding="utf-8") + ) + read_model = normalize_signed_bundle(bundle) + assert len(read_model["artifact_hashes"]) >= 1 + assert all("digest" in row and "name" in row for row in read_model["artifact_hashes"]) + + +def test_source_repo_and_commit_displayed() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _copy_schemas(root) + result = import_signed_bundle( + FIXTURES / "valid_signed_science_claim_bundle.json", + repo_root=root, + write=True, + ) + read_model = json.loads( + (root / "corpus" / "pcs" / "claims" / result.claim_id / "read_model.json").read_text( + encoding="utf-8" + ) + ) + repos = {s["source_repo"] for s in read_model["source_repositories"]} + assert "https://github.com/fraware/LabTrust-Gym" in repos + commits = {s["source_commit"] for s in read_model["source_repositories"]} + assert "labtrust-qc-release-demo" in commits + + +def test_status_values_preserved() -> None: + bundle = json.loads( + (FIXTURES / "valid_signed_science_claim_bundle.json").read_text(encoding="utf-8") + ) + read_model = normalize_signed_bundle(bundle) + assert read_model["claim"]["status"] == "RuntimeChecked" + assert read_model["trace_certificate"]["status"] == "CertificateChecked" + vr = read_model["verification_result"] + assert vr is not None + assert vr["status"] == "ProofChecked" + + +def _copy_schemas(root: Path) -> None: + dest = root / "schemas" / "pcs" + dest.mkdir(parents=True) + for f in (REPO_ROOT / "schemas" / "pcs").glob("*.json"): + shutil.copy(f, dest / f.name) From 074c393a8d30a69dbd8c174e9d59ee1ffebe2b83 Mon Sep 17 00:00:00 2001 From: fraware Date: Sat, 16 May 2026 15:43:35 -0700 Subject: [PATCH 2/3] adding pcs core configuration --- docs/pcs-labtrust-import.md | 9 +- pipeline/pyproject.toml | 6 +- .../pcs_import/artifact_normalizer.py | 264 +++++++++++++++--- .../sm_pipeline/pcs_validate/pcs_core_hook.py | 43 +++ .../sm_pipeline/pcs_validate/stale_checker.py | 9 + .../src/sm_pipeline/pcs_validate/validator.py | 38 ++- portal/components/pcs/RuntimeReceiptView.tsx | 5 + .../components/pcs/VerificationResultView.tsx | 2 +- .../signed_science_claim_bundle.schema.json | 9 +- .../valid_signed_pcs_core_bundle.json | 161 +++++++++++ tests/pcs/test_import_pcs_core_bundle.py | 35 +++ 11 files changed, 521 insertions(+), 60 deletions(-) create mode 100644 pipeline/src/sm_pipeline/pcs_validate/pcs_core_hook.py create mode 100644 tests/pcs/fixtures/valid_signed_pcs_core_bundle.json create mode 100644 tests/pcs/test_import_pcs_core_bundle.py diff --git a/docs/pcs-labtrust-import.md b/docs/pcs-labtrust-import.md index 4478883..de061c9 100644 --- a/docs/pcs-labtrust-import.md +++ b/docs/pcs-labtrust-import.md @@ -10,7 +10,14 @@ Scientific Memory imports **signed** `ScienceClaimBundle` artifacts produced by - Optional top-level `verification_result` (`VerificationResult.v0`) - Top-level `signature_or_digest` -Canonical artifact vocabulary is defined in [pcs-core](https://github.com/SentinelOps-CI/pcs-core). This repository validates against vendored JSON Schemas under `schemas/pcs/` and will call `pcs_core` when that package is installed. +Canonical artifact vocabulary is defined in [pcs-core](https://github.com/SentinelOps-CI/pcs-core). This repository validates against vendored JSON Schemas under `schemas/pcs/` and calls `pcs_core` (editable path dependency in `pipeline/pyproject.toml`) for canonical `ScienceClaimBundle.v0` and `VerificationResult.v0` shapes. + +Two bundle shapes are supported: + +| Shape | Nested claim | Receipt / certificate | +|-------|----------------|----------------------| +| LabTrust portal (legacy) | `science_claim_bundle.claim` | singular `runtime_receipt`, `trace_certificate` | +| pcs-core / Provability Fabric | `science_claim_bundle.claim_artifact` | `runtime_receipts[]`, `certificates[]` | ## Commands diff --git a/pipeline/pyproject.toml b/pipeline/pyproject.toml index d068f10..9724d28 100644 --- a/pipeline/pyproject.toml +++ b/pipeline/pyproject.toml @@ -10,9 +10,13 @@ dependencies = [ "rich>=13.7", "networkx>=3.3", "httpx>=0.27", - "python-dotenv>=1.0" + "python-dotenv>=1.0", + "pcs-core>=0.1.0", ] +[tool.uv.sources] +pcs-core = { path = "../../pcs-core/python", editable = true } + [project.optional-dependencies] mcp = ["mcp>=1.0"] diff --git a/pipeline/src/sm_pipeline/pcs_import/artifact_normalizer.py b/pipeline/src/sm_pipeline/pcs_import/artifact_normalizer.py index 98c9ebf..7c50625 100644 --- a/pipeline/src/sm_pipeline/pcs_import/artifact_normalizer.py +++ b/pipeline/src/sm_pipeline/pcs_import/artifact_normalizer.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from typing import Any GUARANTEE_KEYS = ( @@ -20,10 +21,90 @@ "about a real hospital laboratory." ) +_CHECK_OUTCOME_MAP = { + "passed": "pass", + "failed": "fail", + "skipped": "skip", + "warning": "warn", + "pass": "pass", + "fail": "fail", + "skip": "skip", + "warn": "warn", +} -def _hash_rows(artifact: dict[str, Any] | None) -> list[dict[str, str]]: - if not isinstance(artifact, dict): - return [] + +def _artifact_id(artifact: dict[str, Any]) -> str: + for key in ( + "id", + "artifact_id", + "receipt_id", + "certificate_id", + "assumption_set_id", + "bundle_id", + "verification_id", + ): + val = artifact.get(key) + if isinstance(val, str) and val.strip(): + return val.strip() + return "" + + +def _get_claim(scb: dict[str, Any]) -> dict[str, Any]: + claim = scb.get("claim_artifact") or scb.get("claim") + return claim if isinstance(claim, dict) else {} + + +def _get_runtime_receipt(scb: dict[str, Any]) -> dict[str, Any]: + receipt = scb.get("runtime_receipt") + if isinstance(receipt, dict): + return receipt + receipts = scb.get("runtime_receipts") + if isinstance(receipts, list) and receipts and isinstance(receipts[0], dict): + return receipts[0] + return {} + + +def _get_trace_certificate(scb: dict[str, Any]) -> dict[str, Any]: + cert = scb.get("trace_certificate") + if isinstance(cert, dict): + return cert + certificates = scb.get("certificates") + if isinstance(certificates, list) and certificates and isinstance(certificates[0], dict): + return certificates[0] + return {} + + +def _normalize_assumption(raw: dict[str, Any]) -> dict[str, str]: + return { + "id": str(raw.get("id") or raw.get("assumption_id") or ""), + "text": str(raw.get("text") or ""), + "kind": str(raw.get("kind") or "") or None, + "status": str(raw.get("status") or "") or None, + } + + +def _normalize_assumption_set(raw: dict[str, Any]) -> dict[str, Any]: + assumptions = raw.get("assumptions") + normalized_assumptions: list[dict[str, str | None]] = [] + if isinstance(assumptions, list): + for item in assumptions: + if isinstance(item, dict): + normalized_assumptions.append(_normalize_assumption(item)) + out = dict(raw) + out["id"] = _artifact_id(raw) or out.get("id", "") + out["assumptions"] = normalized_assumptions + return out + + +def _normalize_named_artifact(raw: dict[str, Any]) -> dict[str, Any]: + out = dict(raw) + aid = _artifact_id(raw) + if aid: + out["id"] = aid + return out + + +def _hash_rows_from_list_entries(artifact: dict[str, Any]) -> list[dict[str, str]]: rows: list[dict[str, str]] = [] for field in ("input_hashes", "output_hashes"): entries = artifact.get(field) @@ -40,9 +121,35 @@ def _hash_rows(artifact: dict[str, Any] | None) -> list[dict[str, str]]: "name": name, "digest": digest, "algorithm": str(entry.get("algorithm") or "sha256"), - "source_artifact": str(artifact.get("id") or field), + "source_artifact": str(artifact.get("id") or _artifact_id(artifact) or field), } ) + return rows + + +def _hash_rows_from_map_entries(artifact: dict[str, Any]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for field in ("input_hashes", "output_hashes", "artifact_hashes"): + entries = artifact.get(field) + if not isinstance(entries, dict): + continue + for name, digest in entries.items(): + if isinstance(digest, str) and digest.strip(): + rows.append( + { + "name": str(name), + "digest": digest.strip(), + "algorithm": "sha256", + "source_artifact": str(artifact.get("id") or _artifact_id(artifact) or field), + } + ) + return rows + + +def _hash_rows(artifact: dict[str, Any] | None) -> list[dict[str, str]]: + if not isinstance(artifact, dict): + return [] + rows = _hash_rows_from_list_entries(artifact) + _hash_rows_from_map_entries(artifact) for key in ("trace_hash", "policy_hash", "events_hash", "spec_hash"): val = artifact.get(key) if isinstance(val, str) and val.strip(): @@ -51,9 +158,19 @@ def _hash_rows(artifact: dict[str, Any] | None) -> list[dict[str, str]]: "name": key, "digest": val.strip(), "algorithm": "sha256", - "source_artifact": str(artifact.get("id") or "metadata"), + "source_artifact": str(artifact.get("id") or _artifact_id(artifact) or "metadata"), } ) + sig = artifact.get("signature_or_digest") + if isinstance(sig, str) and sig.strip(): + rows.append( + { + "name": "signature_or_digest", + "digest": sig.strip(), + "algorithm": "sha256", + "source_artifact": str(artifact.get("id") or _artifact_id(artifact) or "artifact"), + } + ) return rows @@ -66,30 +183,104 @@ def _source_metadata(artifact: dict[str, Any] | None) -> dict[str, str]: } +def _normalize_verification_result(vr: dict[str, Any] | None) -> dict[str, Any] | None: + if not isinstance(vr, dict): + return None + checks_out: list[dict[str, str]] = [] + for check in vr.get("checks") or []: + if not isinstance(check, dict): + continue + raw_outcome = check.get("outcome") or check.get("status") or "" + outcome = _CHECK_OUTCOME_MAP.get(str(raw_outcome).lower(), str(raw_outcome)) + details = check.get("details") + detail_text = check.get("detail") + if detail_text is None and isinstance(details, dict): + detail_text = json.dumps(details, sort_keys=True) + elif detail_text is None and details is not None: + detail_text = str(details) + checks_out.append( + { + "id": str(check.get("id") or check.get("check_id") or ""), + "name": str(check.get("name") or check.get("description") or ""), + "outcome": outcome, + "detail": str(detail_text or ""), + "guarantee_type": str(check.get("guarantee_type") or "") or None, + } + ) + overall = vr.get("overall_outcome") + if overall is None and vr.get("status"): + status = str(vr["status"]) + if status in ("ProofChecked", "CertificateChecked", "RuntimeChecked"): + overall = "pass" + return { + "id": _artifact_id(vr) or vr.get("verification_id"), + "status": str(vr.get("status") or ""), + "overall_outcome": overall, + "signature_or_digest": str(vr.get("signature_or_digest") or ""), + "source_repo": str(vr.get("source_repo") or ""), + "source_commit": str(vr.get("source_commit") or ""), + "checks": checks_out, + } + + +def _infer_guarantee_types( + claim: dict[str, Any], + runtime_receipt: dict[str, Any], + trace_certificate: dict[str, Any], + verification_result: dict[str, Any] | None, +) -> dict[str, bool]: + guarantee_types = claim.get("guarantee_types") + if isinstance(guarantee_types, dict): + return {k: bool(guarantee_types.get(k)) for k in GUARANTEE_KEYS} + + inferred = {k: False for k in GUARANTEE_KEYS} + runtime_status = str(runtime_receipt.get("status") or "") + inferred["runtime_observed"] = runtime_status in ("RuntimeObserved", "RuntimeChecked") + inferred["certificate_checked"] = str(trace_certificate.get("status") or "") == ( + "CertificateChecked" + ) + if isinstance(verification_result, dict): + for check in verification_result.get("checks") or []: + if not isinstance(check, dict): + continue + gt = str(check.get("guarantee_type") or "") + outcome = str(check.get("outcome") or "") + if gt in GUARANTEE_KEYS and outcome == "pass": + inferred[gt] = True + if outcome == "pass" and gt == "formally_checked": + inferred["formally_checked"] = True + return inferred + + def normalize_signed_bundle(bundle: dict[str, Any]) -> dict[str, Any]: """Build durable portal read model from a signed ScienceClaimBundle.""" scb = bundle["science_claim_bundle"] - claim = scb["claim"] - claim_id = str(claim["id"]) + claim_raw = _get_claim(scb) + claim_id = _artifact_id(claim_raw) - vr = bundle.get("verification_result") - if vr is None: - vr = scb.get("verification_result") + vr_raw = bundle.get("verification_result") + if vr_raw is None: + vr_raw = scb.get("verification_result") + verification_result = _normalize_verification_result( + vr_raw if isinstance(vr_raw, dict) else None + ) - assumption_set = scb.get("assumption_set") or {} - runtime_receipt = scb.get("runtime_receipt") or {} - trace_certificate = scb.get("trace_certificate") or {} + assumption_set = _normalize_assumption_set( + scb.get("assumption_set") if isinstance(scb.get("assumption_set"), dict) else {} + ) + runtime_receipt = _normalize_named_artifact(_get_runtime_receipt(scb)) + trace_certificate = _normalize_named_artifact(_get_trace_certificate(scb)) - hash_artifacts = [claim, assumption_set, runtime_receipt, trace_certificate] - if isinstance(vr, dict): - hash_artifacts.append(vr) + hash_artifacts: list[dict[str, Any]] = [claim_raw, assumption_set, runtime_receipt, trace_certificate] + if isinstance(vr_raw, dict): + hash_artifacts.append(vr_raw) evidence = scb.get("evidence_bundle") if isinstance(evidence, dict): hash_artifacts.append(evidence) artifact_hashes: list[dict[str, str]] = [] for art in hash_artifacts: - artifact_hashes.extend(_hash_rows(art if isinstance(art, dict) else None)) + artifact_hashes.extend(_hash_rows(art)) sources: list[dict[str, str]] = [] seen: set[tuple[str, str]] = set() @@ -105,49 +296,42 @@ def normalize_signed_bundle(bundle: dict[str, Any]) -> dict[str, Any]: reproduce = list(bundle.get("reproduce_commands") or scb.get("reproduce_commands") or []) verify = list(bundle.get("verify_commands") or scb.get("verify_commands") or []) + if not verify: + verify = [ + "pf verify science-claim signed_science_claim_bundle.json", + "just pcs-validate-bundle BUNDLE=signed_science_claim_bundle.json", + ] limitations = list(scb.get("limitations") or []) if LIMITATION_NOTICE not in limitations: limitations = [LIMITATION_NOTICE, *limitations] - guarantee_types = claim.get("guarantee_types") - if not isinstance(guarantee_types, dict): - guarantee_types = {k: False for k in GUARANTEE_KEYS} - guarantee_types["runtime_observed"] = str(runtime_receipt.get("status")) in ( - "RuntimeObserved", - "RuntimeChecked", - ) - guarantee_types["certificate_checked"] = str(trace_certificate.get("status")) == ( - "CertificateChecked" - ) - if isinstance(vr, dict): - for check in vr.get("checks") or []: - if not isinstance(check, dict): - continue - gt = str(check.get("guarantee_type") or "") - if gt in GUARANTEE_KEYS and check.get("outcome") == "pass": - guarantee_types[gt] = True + guarantee_types = _infer_guarantee_types( + claim_raw, runtime_receipt, trace_certificate, verification_result + ) return { "schema_version": "PcsClaimReadModel.v0", "claim_id": claim_id, "claim": { "id": claim_id, - "text": str(claim.get("claim_text") or ""), - "status": str(claim.get("status") or ""), - "signature_or_digest": str(claim.get("signature_or_digest") or ""), + "text": str(claim_raw.get("claim_text") or ""), + "status": str(claim_raw.get("status") or ""), + "signature_or_digest": str(claim_raw.get("signature_or_digest") or ""), "guarantee_types": guarantee_types, - **{k: claim.get(k) for k in ("producer", "producer_version", "created_at")}, + **{k: claim_raw.get(k) for k in ("producer", "producer_version", "created_at")}, }, "assumption_set": assumption_set, "runtime_receipt": runtime_receipt, "trace_certificate": trace_certificate, - "verification_result": vr, + "verification_result": verification_result, "artifact_hashes": artifact_hashes, "source_repositories": sources, "reproduce_commands": reproduce, "verify_commands": verify, "limitations": limitations, "limitation_notice": LIMITATION_NOTICE, - "bundle_signature_or_digest": str(bundle.get("signature_or_digest") or ""), + "bundle_signature_or_digest": str( + bundle.get("signature_or_digest") or bundle.get("bundle_digest") or "" + ), } diff --git a/pipeline/src/sm_pipeline/pcs_validate/pcs_core_hook.py b/pipeline/src/sm_pipeline/pcs_validate/pcs_core_hook.py new file mode 100644 index 0000000..59c719c --- /dev/null +++ b/pipeline/src/sm_pipeline/pcs_validate/pcs_core_hook.py @@ -0,0 +1,43 @@ +"""Optional validation via pcs-core (canonical PCS protocol).""" + +from __future__ import annotations + +from typing import Any + + +def is_pcs_core_science_claim_bundle(scb: dict[str, Any]) -> bool: + if scb.get("schema_version") == "v0": + return True + return "claim_artifact" in scb + + +def is_pcs_core_verification_result(vr: dict[str, Any]) -> bool: + return "verification_id" in vr and "verifier" in vr + + +def validate_with_pcs_core(bundle: dict[str, Any]) -> list[str]: + """Run pcs-core schema + semantic validation when the package is installed.""" + try: + from pcs_core.validate import ValidationError as PcsCoreValidationError + from pcs_core.validate import validate_artifact + except ImportError: + return [] + + errors: list[str] = [] + scb = bundle.get("science_claim_bundle") + if isinstance(scb, dict) and is_pcs_core_science_claim_bundle(scb): + try: + validate_artifact(scb, "ScienceClaimBundle.v0") + except PcsCoreValidationError as exc: + errors.extend(exc.errors or [str(exc)]) + + vr = bundle.get("verification_result") + if vr is None and isinstance(scb, dict): + vr = scb.get("verification_result") + if isinstance(vr, dict) and is_pcs_core_verification_result(vr): + try: + validate_artifact(vr, "VerificationResult.v0") + except PcsCoreValidationError as exc: + errors.extend(exc.errors or [str(exc)]) + + return errors diff --git a/pipeline/src/sm_pipeline/pcs_validate/stale_checker.py b/pipeline/src/sm_pipeline/pcs_validate/stale_checker.py index 192e41f..2bc571d 100644 --- a/pipeline/src/sm_pipeline/pcs_validate/stale_checker.py +++ b/pipeline/src/sm_pipeline/pcs_validate/stale_checker.py @@ -17,6 +17,7 @@ def find_stale_artifacts(bundle: dict[str, Any]) -> list[str]: for key in ( "claim", + "claim_artifact", "assumption_set", "runtime_receipt", "trace_certificate", @@ -27,6 +28,14 @@ def find_stale_artifacts(bundle: dict[str, Any]) -> list[str]: if isinstance(artifact, dict): _check_artifact(f"science_claim_bundle.{key}", artifact, stale) + for idx, receipt in enumerate(scb.get("runtime_receipts") or []): + if isinstance(receipt, dict): + _check_artifact(f"science_claim_bundle.runtime_receipts.{idx}", receipt, stale) + + for idx, cert in enumerate(scb.get("certificates") or []): + if isinstance(cert, dict): + _check_artifact(f"science_claim_bundle.certificates.{idx}", cert, stale) + vr = bundle.get("verification_result") if isinstance(vr, dict): _check_artifact("verification_result", vr, stale) diff --git a/pipeline/src/sm_pipeline/pcs_validate/validator.py b/pipeline/src/sm_pipeline/pcs_validate/validator.py index 5c33cc6..a617a08 100644 --- a/pipeline/src/sm_pipeline/pcs_validate/validator.py +++ b/pipeline/src/sm_pipeline/pcs_validate/validator.py @@ -10,6 +10,12 @@ from referencing import Registry, Resource from referencing.jsonschema import DRAFT202012 +from sm_pipeline.pcs_validate.pcs_core_hook import ( + is_pcs_core_science_claim_bundle, + is_pcs_core_verification_result, + validate_with_pcs_core, +) + PCS_SCHEMA_BASE_URI = "https://scientific-memory.org/schemas/pcs/" @@ -51,18 +57,6 @@ def validator_for(schema_name: str, repo_root: Path) -> Draft202012Validator: return Draft202012Validator(schema, registry=registry) -def _try_pcs_core_validate(bundle: dict[str, Any]) -> None: - """Optional pcs-core validation hook (no-op if package unavailable).""" - try: - from pcs_core.validate import validate_signed_science_claim_bundle # type: ignore - except ImportError: - try: - from pcs_core import validate_signed_science_claim_bundle # type: ignore - except ImportError: - return - validate_signed_science_claim_bundle(bundle) - - def validate_signed_bundle( bundle: dict[str, Any], *, @@ -79,14 +73,14 @@ def validate_signed_bundle( errors: list[str] = [] if strict: - _try_pcs_core_validate(bundle) + errors.extend(validate_with_pcs_core(bundle)) validator = validator_for("signed_science_claim_bundle.schema.json", root) for err in sorted(validator.iter_errors(bundle), key=lambda e: e.path): errors.append(err.message) scb = bundle.get("science_claim_bundle") - if isinstance(scb, dict): + if isinstance(scb, dict) and not is_pcs_core_science_claim_bundle(scb): scb_validator = validator_for("science_claim_bundle.schema.json", root) for err in sorted(scb_validator.iter_errors(scb), key=lambda e: e.path): errors.append(f"science_claim_bundle: {err.message}") @@ -100,10 +94,20 @@ def validate_signed_bundle( if not assumptions: errors.append("science_claim_bundle.assumption_set.assumptions is required") + if isinstance(scb, dict) and is_pcs_core_science_claim_bundle(scb): + assumption_set = scb.get("assumption_set") + assumptions = ( + assumption_set.get("assumptions") + if isinstance(assumption_set, dict) + else None + ) + if not assumptions: + errors.append("science_claim_bundle.assumption_set.assumptions is required") + vr = bundle.get("verification_result") if vr is None and isinstance(scb, dict): vr = scb.get("verification_result") - if isinstance(vr, dict): + if isinstance(vr, dict) and not is_pcs_core_verification_result(vr): vr_validator = validator_for("verification_result.schema.json", root) for err in sorted(vr_validator.iter_errors(vr), key=lambda e: e.path): errors.append(f"verification_result: {err.message}") @@ -128,6 +132,10 @@ def collect_import_warnings(bundle: dict[str, Any]) -> list[str]: warnings.append("VerificationResult is absent; import proceeds with advisory only.") trace_cert = scb.get("trace_certificate") + if not isinstance(trace_cert, dict): + certificates = scb.get("certificates") + if isinstance(certificates, list) and certificates and isinstance(certificates[0], dict): + trace_cert = certificates[0] if isinstance(trace_cert, dict): status = str(trace_cert.get("status") or "") if status != "CertificateChecked": diff --git a/portal/components/pcs/RuntimeReceiptView.tsx b/portal/components/pcs/RuntimeReceiptView.tsx index f3f2418..9fc3f94 100644 --- a/portal/components/pcs/RuntimeReceiptView.tsx +++ b/portal/components/pcs/RuntimeReceiptView.tsx @@ -20,6 +20,11 @@ export function RuntimeReceiptView({ receipt }: RuntimeReceiptViewProps) { {receipt.summary != null && (

    {String(receipt.summary)}

    )} + {receipt.trace_hash != null && ( +

    + trace_hash: {String(receipt.trace_hash)} +

    + )} {receipt.payload != null && (
               {JSON.stringify(receipt.payload, null, 2)}
    diff --git a/portal/components/pcs/VerificationResultView.tsx b/portal/components/pcs/VerificationResultView.tsx
    index 8d35536..9d84730 100644
    --- a/portal/components/pcs/VerificationResultView.tsx
    +++ b/portal/components/pcs/VerificationResultView.tsx
    @@ -39,7 +39,7 @@ export function VerificationResultView({ result }: VerificationResultViewProps)
           

      {checks.map((c) => ( -
    • +
    • {c.name} F[0,24h] verified)", + "certificate_refs": ["cert-trace-qc-release-v0.1"], + "runtime_receipt_refs": ["receipt-qc-release-run-001"], + "created_at": "2026-05-16T12:05:00Z", + "producer": "labtrust-gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "signature_or_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222" + }, + "assumption_set": { + "assumption_set_id": "as-labtrust-qc-v0.1", + "schema_version": "v0", + "created_at": "2026-05-16T12:00:00Z", + "producer": "labtrust-gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "assumptions": [ + { + "assumption_id": "asm-domain-qc-sim", + "text": "Simulation models a hospital QC release workflow, not a live clinical laboratory.", + "kind": "domain", + "status": "HumanReviewed", + "source_span_refs": ["span-qc-release-spec-1"] + } + ], + "human_review_status": "approved", + "status": "HumanReviewed", + "signature_or_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + }, + "runtime_receipts": [ + { + "receipt_id": "receipt-qc-release-run-001", + "schema_version": "v0", + "run_id": "runs/qc-release", + "environment": { "platform": "linux", "labtrust_version": "0.1.0" }, + "started_at": "2026-05-16T11:58:00Z", + "ended_at": "2026-05-16T12:00:00Z", + "status": "RuntimeObserved", + "events_hash": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "policy_hash": "sha256:4444444444444444444444444444444444444444444444444444444444444444", + "trace_hash": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "producer": "labtrust-gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "input_hashes": { + "spec": "sha256:6666666666666666666666666666666666666666666666666666666666666666" + }, + "output_hashes": { + "trace.json": "sha256:5555555555555555555555555555555555555555555555555555555555555555" + }, + "signature_or_digest": "sha256:7777777777777777777777777777777777777777777777777777777777777777" + } + ], + "certificates": [ + { + "certificate_id": "cert-trace-qc-release-v0.1", + "schema_version": "v0", + "trace_hash": "sha256:5555555555555555555555555555555555555555555555555555555555555555", + "spec_hash": "sha256:6666666666666666666666666666666666666666666666666666666666666666", + "property_id": "qc_release.temporal.safety", + "checker": "certifyedge", + "checker_version": "0.1.0", + "status": "CertificateChecked", + "counterexample_ref": null, + "created_at": "2026-05-16T12:10:00Z", + "producer": "certifyedge", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/CertifyEdge", + "source_commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "signature_or_digest": "sha256:8888888888888888888888888888888888888888888888888888888888888888" + } + ], + "evidence_bundle": { + "bundle_id": "evidence-qc-release-v0.1", + "schema_version": "v0", + "claim_refs": ["claim-qc-release-v0.1"], + "assumption_set_refs": ["as-labtrust-qc-v0.1"], + "runtime_receipt_refs": ["receipt-qc-release-run-001"], + "certificate_refs": ["cert-trace-qc-release-v0.1"], + "artifact_hashes": { + "claim-qc-release-v0.1": "sha256:2222222222222222222222222222222222222222222222222222222222222222" + }, + "created_at": "2026-05-16T12:12:00Z", + "producer": "labtrust-gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "signature_or_digest": "sha256:9999999999999999999999999999999999999999999999999999999999999999" + }, + "verification_policy": { + "policy_id": "labtrust-v0.1-qc-release", + "required_checks": ["schema-valid", "trace-hash-alignment"] + }, + "created_at": "2026-05-16T12:15:00Z", + "producer": "labtrust-gym", + "producer_version": "0.1.0", + "source_repo": "https://github.com/fraware/LabTrust-Gym", + "source_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "signature_or_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } +} diff --git a/tests/pcs/test_import_pcs_core_bundle.py b/tests/pcs/test_import_pcs_core_bundle.py new file mode 100644 index 0000000..49bcf88 --- /dev/null +++ b/tests/pcs/test_import_pcs_core_bundle.py @@ -0,0 +1,35 @@ +import json +import shutil +import tempfile +from pathlib import Path + +from sm_pipeline.pcs_import.science_claim_bundle_importer import import_signed_bundle + +FIXTURES = Path(__file__).resolve().parent / "fixtures" +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_pcs_core_signed_bundle_imports() -> None: + bundle_path = FIXTURES / "valid_signed_pcs_core_bundle.json" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _copy_schemas(root) + result = import_signed_bundle(bundle_path, repo_root=root, write=True) + assert result.claim_id == "claim-qc-release-v0.1" + read_model = json.loads( + (root / "corpus" / "pcs" / "claims" / result.claim_id / "read_model.json").read_text( + encoding="utf-8" + ) + ) + assert read_model["claim"]["text"] + assert read_model["trace_certificate"]["status"] == "CertificateChecked" + assert read_model["verification_result"] is not None + assert read_model["verification_result"]["checks"][0]["outcome"] == "pass" + assert any(h["name"] == "trace_hash" for h in read_model["artifact_hashes"]) + + +def _copy_schemas(root: Path) -> None: + dest = root / "schemas" / "pcs" + dest.mkdir(parents=True) + for f in (REPO_ROOT / "schemas" / "pcs").glob("*.json"): + shutil.copy(f, dest / f.name) From 9788cef40b22c92081f4ddb8282fd5ce37902708 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 22:46:06 +0000 Subject: [PATCH 3/3] Update rich requirement from >=13.7 to >=15.0.0 in /pipeline Updates the requirements on [rich](https://github.com/Textualize/rich) to permit the latest version. - [Release notes](https://github.com/Textualize/rich/releases) - [Changelog](https://github.com/Textualize/rich/blob/master/CHANGELOG.md) - [Commits](https://github.com/Textualize/rich/compare/v13.7.0...v15.0.0) --- updated-dependencies: - dependency-name: rich dependency-version: 15.0.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pipeline/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pipeline/pyproject.toml b/pipeline/pyproject.toml index 9724d28..66f58ca 100644 --- a/pipeline/pyproject.toml +++ b/pipeline/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "jsonschema>=4.23", "referencing>=0.35", "typer>=0.12", - "rich>=13.7", + "rich>=15.0.0", "networkx>=3.3", "httpx>=0.27", "python-dotenv>=1.0",