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.
+
+ ) : (
+
+ )}
+
+ );
+}
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.
+ ) : (
+
+
+
+
+ | Name |
+ Digest |
+ Algorithm |
+ Source artifact |
+
+
+
+ {hashes.map((row) => (
+
+ | {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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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.
+ ) : (
+
+ )}
+
+ );
+}
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}
+
+
+ )}
+
+
+
+ );
+}
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",