diff --git a/.github/workflows/CreateReleaseDraft.yml b/.github/workflows/CreateReleaseDraft.yml index 4603e5db0..472368b72 100644 --- a/.github/workflows/CreateReleaseDraft.yml +++ b/.github/workflows/CreateReleaseDraft.yml @@ -75,25 +75,23 @@ jobs: env: GH_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} - # Both installer workflows are workflow_dispatch-only. The commit filter and - # head-SHA check keep unrelated artifacts out of the release tag. + # A workflow_dispatch run's headSha identifies the ref used to load the + # workflow, not the source_sha input checked out by the packaging job. Select + # by the immutable SHA embedded in run-name; the downloaded evidence below is + # the authoritative cross-platform provenance check. - name: Get latest run ID for Linux_AppImage id: get_linux_run_id run: | set -euo pipefail latest_run=$(gh run list --workflow=Linux_AppImage \ - --commit "$EXPECTED_SOURCE_SHA" --status success \ - --json databaseId,headSha --jq '.[0] // empty') + --event workflow_dispatch --status success --limit 100 \ + --json databaseId,displayTitle \ + | jq -c --arg title "Linux_AppImage (${EXPECTED_SOURCE_SHA})" \ + 'map(select(.displayTitle == $title))[0] // empty') if [ -z "$latest_run" ]; then echo "::error::No successful Linux_AppImage run found for source SHA ${EXPECTED_SOURCE_SHA}." exit 1 fi - run_sha=$(echo "$latest_run" | jq -r .headSha) - if [ "${run_sha,,}" != "${EXPECTED_SOURCE_SHA,,}" ]; then - echo "::error::Linux_AppImage artifacts were built from ${run_sha}, but source_sha is ${EXPECTED_SOURCE_SHA}." - echo "::error::Re-run Linux_AppImage on this exact source commit before creating the release draft." - exit 1 - fi echo "linux_run_id=$(echo "$latest_run" | jq -r .databaseId)" >> $GITHUB_ENV env: GH_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} @@ -104,18 +102,14 @@ jobs: run: | set -euo pipefail latest_run=$(gh run list --workflow=Windows_MSI \ - --commit "$EXPECTED_SOURCE_SHA" --status success \ - --json databaseId,headSha --jq '.[0] // empty') + --event workflow_dispatch --status success --limit 100 \ + --json databaseId,displayTitle \ + | jq -c --arg title "Windows_MSI (${EXPECTED_SOURCE_SHA})" \ + 'map(select(.displayTitle == $title))[0] // empty') if [ -z "$latest_run" ]; then echo "::error::No successful Windows_MSI run found for source SHA ${EXPECTED_SOURCE_SHA}." exit 1 fi - run_sha=$(echo "$latest_run" | jq -r .headSha) - if [ "${run_sha,,}" != "${EXPECTED_SOURCE_SHA,,}" ]; then - echo "::error::Windows_MSI artifacts were built from ${run_sha}, but source_sha is ${EXPECTED_SOURCE_SHA}." - echo "::error::Re-run Windows_MSI on this exact source commit before creating the release draft." - exit 1 - fi echo "windows_run_id=$(echo "$latest_run" | jq -r .databaseId)" >> $GITHUB_ENV env: GH_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} diff --git a/.github/workflows/LinuxInstall.yml b/.github/workflows/LinuxInstall.yml index 9e0f90f5c..29c9c8911 100644 --- a/.github/workflows/LinuxInstall.yml +++ b/.github/workflows/LinuxInstall.yml @@ -1,4 +1,5 @@ name: Linux_AppImage +run-name: Linux_AppImage (${{ inputs.source_sha }}) on: # Dispatch-only. PRs targeting `dev` run ci.yml; MSI/AppImage qualification @@ -301,6 +302,27 @@ jobs: --output "$evidence_dir/evidence.json" \ --report "$evidence_dir/inspection.txt" 2>&1 | tee "$evidence_dir/inspector.txt" + - name: Generate final-artifact SBOM and notices + working-directory: loop + run: | + evidence_dir="$RUNNER_TEMP/loop-package-boundary-linux" + python3 scripts/ci/generate_package_sbom.py \ + --evidence "$evidence_dir/evidence.json" \ + --output "$evidence_dir/components.spdx.json" + python3 scripts/ci/generate_package_third_party_notices.py \ + --evidence "$evidence_dir/evidence.json" \ + --output "$evidence_dir/THIRD_PARTY_NOTICES.txt" + + - name: Run Qt LGPL relink test + working-directory: loop + env: + QT_QPA_PLATFORM: offscreen + run: | + evidence_dir="$RUNNER_TEMP/loop-package-boundary-linux" + bash scripts/ci/run_qt_relink_test.sh \ + "build/${{ env.appimagefilename }}" \ + --output "$evidence_dir/qt-relink.txt" + - name: Upload Linux package boundary evidence if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 diff --git a/.github/workflows/WindowsInstall.yml b/.github/workflows/WindowsInstall.yml index 48a2a5f0e..5d0f1bf9c 100644 --- a/.github/workflows/WindowsInstall.yml +++ b/.github/workflows/WindowsInstall.yml @@ -1,4 +1,5 @@ name: Windows_MSI +run-name: Windows_MSI (${{ inputs.source_sha }}) on: # Dispatch-only. PRs targeting `dev` run ci.yml; MSI/AppImage qualification @@ -449,20 +450,35 @@ jobs: $inspectorOutput | Tee-Object -FilePath (Join-Path $evidenceDir "inspector.txt") if ($LASTEXITCODE -ne 0) { throw "MSI package boundary inspection failed." } + - name: Generate final-artifact SBOM and notices + shell: pwsh + run: | + $evidenceDir = Join-Path $env:RUNNER_TEMP "loop-package-boundary-windows" + python ".\loop\scripts\ci\generate_package_sbom.py" ` + --evidence (Join-Path $evidenceDir "evidence.json") ` + --output (Join-Path $evidenceDir "components.spdx.json") + python ".\loop\scripts\ci\generate_package_third_party_notices.py" ` + --evidence (Join-Path $evidenceDir "evidence.json") ` + --output (Join-Path $evidenceDir "THIRD_PARTY_NOTICES.txt") + if ($LASTEXITCODE -ne 0) { throw "Final-artifact notices generation failed." } + - name: Run MSI lifecycle smoke test shell: pwsh run: | # The release MSI is x64 and must install beneath 64-bit Program Files. # The full editor operator launch is skipped on the hosted runner; the # packaged native/software Quick startup is exercised by the smoke script. + $evidenceDir = Join-Path $env:RUNNER_TEMP "loop-package-boundary-windows" + New-Item -ItemType Directory -Force -Path $evidenceDir | Out-Null $msiPath = Join-Path $env:GITHUB_WORKSPACE "loop\build\install\${{ env.msipackagefilename }}" $installDir = Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "LOOP" + $evidenceDir = Join-Path $env:RUNNER_TEMP "loop-package-boundary-windows" & "${env:GITHUB_WORKSPACE}\loop\scripts\Invoke-MsiSmokeTest.ps1" ` -MsiPath $msiPath ` -InstallDir $installDir ` -SourceSha $env:LOOP_SOURCE_SHA ` - -SkipEditorLaunch - + -SkipEditorLaunch ` + -QtRelinkTranscript (Join-Path $evidenceDir "qt-relink.txt") - name: Upload Windows package boundary evidence if: always() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7939bb0c9..a609bfba6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,20 @@ permissions: contents: read jobs: + package_script_tests: + strategy: + matrix: + os: [ubuntu-22.04, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Test package lifecycle scripts with fake packages + run: python -m unittest scripts.ci.test_run_qt_relink_test -v + - name: Test Linux AppImage Qt relink script with fake AppImage + if: runner.os == 'Linux' + run: python -m unittest scripts.ci.test_run_qt_relink_linux -v + source_integrity: runs-on: ubuntu-24.04 steps: diff --git a/.github/workflows/reusable-linux.yml b/.github/workflows/reusable-linux.yml index e5d1022de..e5d601905 100644 --- a/.github/workflows/reusable-linux.yml +++ b/.github/workflows/reusable-linux.yml @@ -75,6 +75,15 @@ jobs: - name: Verify version policy working-directory: loop run: python3 scripts/ci/check_version_policy.py + - name: Verify resource-envelope contracts + working-directory: loop + run: | + python3 -m unittest scripts.resource_envelope.test_validate_envelope scripts.resource_envelope.test_pathological_workload scripts.resource_envelope.test_run_matrix scripts.resource_envelope.test_budget_exhaustion_corpus scripts.qualification.test_validate_resource_envelope_evidence -v + python3 scripts/resource_envelope/validate_envelope.py docs/generated/huge-document-envelope.json + python3 scripts/qualification/validate_resource_envelope_evidence.py + python3 scripts/resource_envelope/pathological_workload.py \ + --output "${RUNNER_TEMP}/loop-pathological-vector.pdf" \ + --page-count 256 --operations 256 --family pathological-vector - name: Verify processing-budget exhaustion corpus working-directory: loop run: python3 scripts/budget_exhaustion/generate_corpus.py --check diff --git a/.github/workflows/reusable-windows.yml b/.github/workflows/reusable-windows.yml index d45552f4b..2fd91ea3c 100644 --- a/.github/workflows/reusable-windows.yml +++ b/.github/workflows/reusable-windows.yml @@ -82,8 +82,9 @@ jobs: working-directory: loop shell: pwsh run: | - python -m unittest scripts.resource_envelope.test_validate_envelope scripts.resource_envelope.test_pathological_workload -v + python -m unittest scripts.resource_envelope.test_validate_envelope scripts.resource_envelope.test_pathological_workload scripts.resource_envelope.test_run_matrix scripts.resource_envelope.test_budget_exhaustion_corpus scripts.qualification.test_validate_resource_envelope_evidence -v python scripts/resource_envelope/validate_envelope.py docs/generated/huge-document-envelope.json + python scripts/qualification/validate_resource_envelope_evidence.py python scripts/resource_envelope/pathological_workload.py ` --output "$env:RUNNER_TEMP\loop-pathological-vector.pdf" ` --page-count 256 --operations 256 --family pathological-vector diff --git a/LoopLibCore/sources/pdfdiff.cpp b/LoopLibCore/sources/pdfdiff.cpp index 5c9899258..8d9c0b9f3 100644 --- a/LoopLibCore/sources/pdfdiff.cpp +++ b/LoopLibCore/sources/pdfdiff.cpp @@ -28,9 +28,12 @@ #include "pdfcms.h" #include "pdfconstants.h" #include "pdfalgorithmlcs.h" +#include "pdfjobscheduler.h" #include "pdfpainter.h" -#include +#include +#include +#include #include "pdfdbgheap.h" @@ -89,7 +92,6 @@ PDFDiff::PDFDiff(QObject* parent) : m_cancelled(false), m_textAnalysisAlgorithm(PDFDocumentTextFlowFactory::Algorithm::Layout) { - } PDFDiff::~PDFDiff() @@ -138,12 +140,38 @@ void PDFDiff::start() if (m_options.testFlag(Asynchronous)) { - m_futureWatcher = std::nullopt; - m_futureWatcher.emplace(); + if (m_jobFinishedConnection) + { + disconnect(m_jobFinishedConnection); + m_jobFinishedConnection = {}; + } + + pdf::PDFJobSpec spec; + spec.jobId = QStringLiteral("pdf-diff-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); + spec.kind = pdf::PDFJobKind::Batch; + spec.priority = pdf::PDFJobPriority::Background; + spec.operationId = QStringLiteral("pdf.diff"); - m_future = QtConcurrent::run(std::bind(&PDFDiff::perform, this)); - connect(&*m_futureWatcher, &QFutureWatcher::finished, this, &PDFDiff::onComparationPerformed); - m_futureWatcher->setFuture(m_future); + m_activeJobId = pdf::PDFJobScheduler::global().submit(spec, [this](pdf::PDFJobContext& context) + { + if (context.isCancellationRequested()) + { + m_cancelled = true; + return; + } + m_result = perform(); }); + + m_jobFinishedConnection = connect(&pdf::PDFJobScheduler::global(), + &pdf::PDFJobScheduler::jobFinished, + this, + [this](const pdf::PDFJobSnapshot& snapshot) + { + if (snapshot.jobId != m_activeJobId) + { + return; + } + onComparationPerformed(snapshot.status == pdf::PDFJobStatus::Cancelled); + }); } else { @@ -155,12 +183,20 @@ void PDFDiff::start() void PDFDiff::stop() { - if (m_futureWatcher && !m_futureWatcher->isFinished()) + if (m_activeJobId.isEmpty()) + { + return; + } + + const QString jobId = m_activeJobId; + m_cancelled = true; + pdf::PDFJobScheduler::global().cancel(jobId); + pdf::PDFJobScheduler::global().waitForFinished(jobId); + m_activeJobId.clear(); + if (m_jobFinishedConnection) { - // Do stop only if process doesn't finished already. - // If we are finished, we do not want to set cancelled state. - m_cancelled = true; - m_futureWatcher->waitForFinished(); + disconnect(m_jobFinishedConnection); + m_jobFinishedConnection = {}; } } @@ -224,7 +260,7 @@ void PDFDiff::stepProgress() struct PDFDiffPageContext { PDFInteger pageIndex = 0; - std::array pageHash = { }; + std::array pageHash = {}; PDFPrecompiledPage::GraphicPieceInfos graphicPieces; PDFDocumentTextFlow text; }; @@ -342,13 +378,13 @@ void PDFDiff::performSteps(const std::vector& leftPages, std::vector rightPreparedPages; PDFDiffHelper::PageSequence pageSequence; - std::map pageMatches; // Indices are real page indices, not indices to page contexts + std::map pageMatches; // Indices are real page indices, not indices to page contexts auto createDiffPageContext = [](auto pageIndex) { - PDFDiffPageContext context; - context.pageIndex = pageIndex; - return context; + PDFDiffPageContext context; + context.pageIndex = pageIndex; + return context; }; std::transform(leftPages.cbegin(), leftPages.cend(), std::back_inserter(leftPreparedPages), createDiffPageContext); std::transform(rightPages.cbegin(), rightPages.cend(), std::back_inserter(rightPreparedPages), createDiffPageContext); @@ -696,7 +732,7 @@ void PDFDiff::performCompare(const std::vector& leftPrepared compareCharacters); algorithm.perform(); PDFAlgorithmLongestCommonSubsequenceBase::Sequence sequence = algorithm.getSequence(); - PDFAlgorithmLongestCommonSubsequenceBase::markSequence(sequence, { }, { }); + PDFAlgorithmLongestCommonSubsequenceBase::markSequence(sequence, {}, {}); PDFAlgorithmLongestCommonSubsequenceBase::SequenceItemRanges modifiedRanges = PDFAlgorithmLongestCommonSubsequenceBase::getModifiedRanges(sequence); // Merge modified sequences separated by just space @@ -778,9 +814,9 @@ void PDFDiff::performCompare(const std::vector& leftPrepared pageIndex1 = textItem->pageIndex; } - if (static_cast< std::size_t >( textCompareItem.charIndex ) + textCompareItem.charCount <= textItem->characterBoundingRects.size()) + if (static_cast(textCompareItem.charIndex) + textCompareItem.charCount <= textItem->characterBoundingRects.size()) { - const size_t startIndex = textCompareItem.charIndex; + const size_t startIndex = textCompareItem.charIndex; const size_t endIndex = startIndex + textCompareItem.charCount; for (size_t i = startIndex; i < endIndex; ++i) @@ -806,9 +842,9 @@ void PDFDiff::performCompare(const std::vector& leftPrepared pageIndex2 = textItem->pageIndex; } - if (static_cast< std::size_t >(textCompareItem.charIndex) + textCompareItem.charCount <= textItem->characterBoundingRects.size()) + if (static_cast(textCompareItem.charIndex) + textCompareItem.charCount <= textItem->characterBoundingRects.size()) { - const size_t startIndex = textCompareItem.charIndex; + const size_t startIndex = textCompareItem.charIndex; const size_t endIndex = startIndex + textCompareItem.charCount; for (size_t i = startIndex; i < endIndex; ++i) @@ -900,10 +936,15 @@ void PDFDiff::finalizeGraphicsPieces(PDFDiffPageContext& context) std::copy(hash.data(), hash.data() + size, context.pageHash.data()); } -void PDFDiff::onComparationPerformed() +void PDFDiff::onComparationPerformed(bool cancelled) { - m_cancelled = false; - m_result = m_future.result(); + m_cancelled = cancelled; + m_activeJobId.clear(); + if (m_jobFinishedConnection) + { + disconnect(m_jobFinishedConnection); + m_jobFinishedConnection = {}; + } Q_EMIT comparationFinished(); } @@ -933,7 +974,6 @@ void PDFDiff::setTextAnalysisAlgorithm(PDFDocumentTextFlowFactory::Algorithm tex PDFDiffResult::PDFDiffResult() : m_result(true) { - } void PDFDiffResult::addPageMoved(PDFInteger pageIndex1, PDFInteger pageIndex2) @@ -1799,12 +1839,10 @@ PDFDiffResultNavigator::PDFDiffResultNavigator(QObject* parent) : m_diffResult(nullptr), m_currentIndex(0) { - } PDFDiffResultNavigator::~PDFDiffResultNavigator() { - } void PDFDiffResultNavigator::setResult(const PDFDiffResult* diffResult) diff --git a/LoopLibCore/sources/pdfdiff.h b/LoopLibCore/sources/pdfdiff.h index cb69bb859..01b63ccdc 100644 --- a/LoopLibCore/sources/pdfdiff.h +++ b/LoopLibCore/sources/pdfdiff.h @@ -29,9 +29,8 @@ #include "pdfalgorithmlcs.h" #include "pdfdocumenttextflow.h" +#include #include -#include -#include #include @@ -50,21 +49,21 @@ class LOOPLIBCORESHARED_EXPORT PDFDiffResult enum class Type : uint32_t { - Invalid = 0x0000, - PageMoved = 0x0001, - PageAdded = 0x0002, - PageRemoved = 0x0004, - RemovedTextCharContent = 0x0008, - RemovedVectorGraphicContent = 0x0010, - RemovedImageContent = 0x0020, - RemovedShadingContent = 0x0040, - AddedTextCharContent = 0x0080, - AddedVectorGraphicContent = 0x0100, - AddedImageContent = 0x0200, - AddedShadingContent = 0x0400, - TextReplaced = 0x0800, - TextAdded = 0x1000, - TextRemoved = 0x2000, + Invalid = 0x0000, + PageMoved = 0x0001, + PageAdded = 0x0002, + PageRemoved = 0x0004, + RemovedTextCharContent = 0x0008, + RemovedVectorGraphicContent = 0x0010, + RemovedImageContent = 0x0020, + RemovedShadingContent = 0x0040, + AddedTextCharContent = 0x0080, + AddedVectorGraphicContent = 0x0100, + AddedImageContent = 0x0200, + AddedShadingContent = 0x0400, + TextReplaced = 0x0800, + TextAdded = 0x1000, + TextRemoved = 0x2000, }; struct PageSequenceItem @@ -233,7 +232,7 @@ class LOOPLIBCORESHARED_EXPORT PDFDiffResult void addRectRight(Difference& difference, QRectF rect); Differences m_differences; - RectInfos m_rects; ///< Rectangles with page indices + RectInfos m_rects; ///< Rectangles with page indices PDFOperationResult m_result; QStringList m_strings; uint32_t m_typeFlags = 0; @@ -301,14 +300,14 @@ class LOOPLIBCORESHARED_EXPORT PDFDiff : public QObject enum Option { - None = 0x0000, - Asynchronous = 0x0001, ///< Compare document asynchronously - PC_Text = 0x0002, ///< Use text to compare pages (determine, which pages correspond to each other) - PC_VectorGraphics = 0x0004, ///< Use vector graphics to compare pages (determine, which pages correspond to each other) - PC_Images = 0x0008, ///< Use images to compare pages (determine, which pages correspond to each other) - PC_Mesh = 0x0010, ///< Use mesh to compare pages (determine, which pages correspond to each other) - CompareTextsAsVector = 0x0020, ///< Compare texts as vector graphics - CompareWords = 0x0040, ///< Compare words, not just characters + None = 0x0000, + Asynchronous = 0x0001, ///< Compare document asynchronously + PC_Text = 0x0002, ///< Use text to compare pages (determine, which pages correspond to each other) + PC_VectorGraphics = 0x0004, ///< Use vector graphics to compare pages (determine, which pages correspond to each other) + PC_Images = 0x0008, ///< Use images to compare pages (determine, which pages correspond to each other) + PC_Mesh = 0x0010, ///< Use mesh to compare pages (determine, which pages correspond to each other) + CompareTextsAsVector = 0x0020, ///< Compare texts as vector graphics + CompareWords = 0x0040, ///< Compare words, not just characters }; Q_DECLARE_FLAGS(Options, Option) @@ -357,7 +356,6 @@ class LOOPLIBCORESHARED_EXPORT PDFDiff : public QObject void comparationFinished(); private: - enum Steps { StepExtractContentLeftDocument, @@ -385,7 +383,7 @@ class LOOPLIBCORESHARED_EXPORT PDFDiff : public QObject PDFDiffResult& result); void finalizeGraphicsPieces(PDFDiffPageContext& context); - void onComparationPerformed(); + void onComparationPerformed(bool cancelled); /// Calculates real epsilon for a page. Epsilon is used in page /// comparation process, where points closer that epsilon @@ -404,10 +402,10 @@ class LOOPLIBCORESHARED_EXPORT PDFDiff : public QObject PDFDiffResult m_result; PDFDocumentTextFlowFactory::Algorithm m_textAnalysisAlgorithm; - QFuture m_future; - std::optional> m_futureWatcher; + QString m_activeJobId; + QMetaObject::Connection m_jobFinishedConnection; }; } // namespace pdf -#endif // PDFDIFF_H +#endif // PDFDIFF_H diff --git a/LoopLibCore/sources/pdfrepairprimitives.cpp b/LoopLibCore/sources/pdfrepairprimitives.cpp index a6e0bea38..0e1421899 100644 --- a/LoopLibCore/sources/pdfrepairprimitives.cpp +++ b/LoopLibCore/sources/pdfrepairprimitives.cpp @@ -136,6 +136,7 @@ class PDFAddBleedRepair final : public PDFRepairOperation plan->domains = domains(); plan->expectedChanges.pageBoxes = true; plan->expectedChanges.pageContent = true; + plan->expectedChanges.images = true; plan->expectedChanges.metadata = true; plan->validators = { PDFRepairValidatorKind::StructuralIntegrity, PDFRepairValidatorKind::NormalPreflight }; diff --git a/UnitTests/testdata/lifecycle/failure-rollback-history-minimized.json b/UnitTests/testdata/lifecycle/failure-rollback-history-minimized.json new file mode 100644 index 000000000..b7188498f --- /dev/null +++ b/UnitTests/testdata/lifecycle/failure-rollback-history-minimized.json @@ -0,0 +1,29 @@ +{ + "schema_kind": "loop-lifecycle-trace", + "schema_version": 1, + "seed": 539363361, + "initial_artifact_digest": "161a1beb41c008069762349ccae021ac2276deb2314aed2cdcb746a485f4dbcf", + "commands": [ + { + "index": 0, + "kind": "open", + "argument": "9073021129658994722" + }, + { + "index": 1, + "kind": "save-reopen", + "argument": "5643642477061534660" + } + ], + "expected_invariants": [ + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only" + ], + "observed_result": "rollback-history-mutated", + "shrink_history": [ + 64, + 2 + ] +} diff --git a/UnitTests/testdata/lifecycle/failure-source-overwritten-minimized.json b/UnitTests/testdata/lifecycle/failure-source-overwritten-minimized.json new file mode 100644 index 000000000..f4ca466da --- /dev/null +++ b/UnitTests/testdata/lifecycle/failure-source-overwritten-minimized.json @@ -0,0 +1,24 @@ +{ + "schema_kind": "loop-lifecycle-trace", + "schema_version": 1, + "seed": 539363361, + "initial_artifact_digest": "161a1beb41c008069762349ccae021ac2276deb2314aed2cdcb746a485f4dbcf", + "commands": [ + { + "index": 0, + "kind": "open", + "argument": "9073021129658994722" + } + ], + "expected_invariants": [ + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only" + ], + "observed_result": "source-overwritten", + "shrink_history": [ + 64, + 1 + ] +} diff --git a/UnitTests/testdata/lifecycle/failure-stale-result-minimized.json b/UnitTests/testdata/lifecycle/failure-stale-result-minimized.json new file mode 100644 index 000000000..a66e6fcf3 --- /dev/null +++ b/UnitTests/testdata/lifecycle/failure-stale-result-minimized.json @@ -0,0 +1,24 @@ +{ + "schema_kind": "loop-lifecycle-trace", + "schema_version": 1, + "seed": 539363361, + "initial_artifact_digest": "161a1beb41c008069762349ccae021ac2276deb2314aed2cdcb746a485f4dbcf", + "commands": [ + { + "index": 0, + "kind": "open", + "argument": "9073021129658994722" + } + ], + "expected_invariants": [ + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only" + ], + "observed_result": "stale-result-accepted", + "shrink_history": [ + 64, + 1 + ] +} diff --git a/UnitTests/testdata/lifecycle/manifest.json b/UnitTests/testdata/lifecycle/manifest.json new file mode 100644 index 000000000..3b1a0f125 --- /dev/null +++ b/UnitTests/testdata/lifecycle/manifest.json @@ -0,0 +1,59 @@ +{ + "schema_kind": "loop-lifecycle-corpus", + "schema_version": 1, + "max_commands": 64, + "initial_artifact_digest": "161a1beb41c008069762349ccae021ac2276deb2314aed2cdcb746a485f4dbcf", + "passing_traces": [ + { + "seed": 539363361, + "trace_file": "seed-20260821.json", + "command_count": 64, + "sha256": "3e1f42af36600a36a81e67fa57fcd580db12bbfe99f7dd539d592a5cde1f70ed", + "observed_result": "invariants-held" + }, + { + "seed": 539363585, + "trace_file": "seed-20260901.json", + "command_count": 64, + "sha256": "1a86d6679ee11d4bd8fd66aa2fb90ac41b0d6a70c1d0efa0d798158ca5488fb6", + "observed_result": "invariants-held" + }, + { + "seed": 539363590, + "trace_file": "seed-20260906.json", + "command_count": 64, + "sha256": "d5d2ae6d4dbee887dd454cbe262938bbd78f8d286a5d802e784e1eecde436d19", + "observed_result": "invariants-held" + }, + { + "seed": 539365889, + "trace_file": "seed-20261201.json", + "command_count": 64, + "sha256": "13c6207cfaea9553f3a62d59a91b6b1fe22c3fae2c46405cc6309e5d9bc3cd67", + "observed_result": "invariants-held" + } + ], + "failure_traces": [ + { + "trace_file": "failure-stale-result-minimized.json", + "replay_profile": "inject-stale-acceptance", + "expected_violation": "stale-result-accepted", + "command_count": 1, + "sha256": "d4eb0ed9fc6819f98ba9b3361faf854e7a04e6b1bae510280659baaa20e508d9" + }, + { + "trace_file": "failure-source-overwritten-minimized.json", + "replay_profile": "inject-source-overwrite", + "expected_violation": "source-overwritten", + "command_count": 1, + "sha256": "f0af352cb2c2587ee46dcbfe7adac9e880de124f62c65d06a5763ca087fde950" + }, + { + "trace_file": "failure-rollback-history-minimized.json", + "replay_profile": "inject-history-mutation", + "expected_violation": "rollback-history-mutated", + "command_count": 2, + "sha256": "01d976d572d0b0be3f063085f45ec4bd6bc2536eb0502a54e5658fcf53c1664b" + } + ] +} diff --git a/UnitTests/testdata/lifecycle/seed-20260821.json b/UnitTests/testdata/lifecycle/seed-20260821.json index d93dd6413..220d4b4cd 100644 --- a/UnitTests/testdata/lifecycle/seed-20260821.json +++ b/UnitTests/testdata/lifecycle/seed-20260821.json @@ -161,8 +161,168 @@ }, { "index": 31, + "kind": "open", + "argument": "5013354945698045938" + }, + { + "index": 32, + "kind": "cancel", + "argument": "11197343878333884823" + }, + { + "index": 33, + "kind": "open", + "argument": "1066545918793467191" + }, + { + "index": 34, + "kind": "open", + "argument": "11321594274765674640" + }, + { + "index": 35, + "kind": "replace-revision", + "argument": "14283579915496753648" + }, + { + "index": 36, + "kind": "cancel", + "argument": "17863176575058370743" + }, + { + "index": 37, + "kind": "cancel", + "argument": "13685223271187027546" + }, + { + "index": 38, + "kind": "rollback", + "argument": "6748766380856173228" + }, + { + "index": 39, + "kind": "open", + "argument": "9604188461958675080" + }, + { + "index": 40, + "kind": "rollback", + "argument": "976639850783419330" + }, + { + "index": 41, + "kind": "rollback", + "argument": "11029822007145908066" + }, + { + "index": 42, + "kind": "cancel", + "argument": "9746768580576956141" + }, + { + "index": 43, + "kind": "cancel", + "argument": "2957123111136537141" + }, + { + "index": 44, + "kind": "save-reopen", + "argument": "1534177130836295959" + }, + { + "index": 45, + "kind": "rollback", + "argument": "11824983345393850086" + }, + { + "index": 46, + "kind": "replace-revision", + "argument": "10514359223331479425" + }, + { + "index": 47, + "kind": "cancel", + "argument": "14023964117627326189" + }, + { + "index": 48, + "kind": "save-reopen", + "argument": "1159594577666376080" + }, + { + "index": 49, + "kind": "cancel", + "argument": "5253177320728505183" + }, + { + "index": 50, + "kind": "render-preflight", + "argument": "15684130718138178927" + }, + { + "index": 51, + "kind": "save-reopen", + "argument": "17776524391223397062" + }, + { + "index": 52, + "kind": "open", + "argument": "7526981500392177173" + }, + { + "index": 53, + "kind": "cancel", + "argument": "6451505757555346948" + }, + { + "index": 54, + "kind": "rollback", + "argument": "8369269387004969562" + }, + { + "index": 55, + "kind": "render-preflight", + "argument": "16441652591749629825" + }, + { + "index": 56, + "kind": "save-reopen", + "argument": "6520366602141516188" + }, + { + "index": 57, + "kind": "open", + "argument": "17566257490975440028" + }, + { + "index": 58, + "kind": "replace-revision", + "argument": "8693436164466649310" + }, + { + "index": 59, + "kind": "rollback", + "argument": "10201475227484907525" + }, + { + "index": 60, + "kind": "cancel", + "argument": "8702028942361848727" + }, + { + "index": 61, + "kind": "cancel", + "argument": "7680792602989575571" + }, + { + "index": 62, + "kind": "open", + "argument": "18431997874931135203" + }, + { + "index": 63, "kind": "close", - "argument": "14977176080434032300" + "argument": "17475550920163749414" } ], "expected_invariants": [ @@ -170,5 +330,7 @@ "cancel-is-terminal", "stale-results-rejected", "history-append-only" - ] + ], + "observed_result": "invariants-held", + "shrink_history": [] } diff --git a/UnitTests/testdata/lifecycle/seed-20260901.json b/UnitTests/testdata/lifecycle/seed-20260901.json new file mode 100644 index 000000000..bc300a86e --- /dev/null +++ b/UnitTests/testdata/lifecycle/seed-20260901.json @@ -0,0 +1,336 @@ +{ + "schema_kind": "loop-lifecycle-trace", + "schema_version": 1, + "seed": 539363585, + "initial_artifact_digest": "161a1beb41c008069762349ccae021ac2276deb2314aed2cdcb746a485f4dbcf", + "commands": [ + { + "index": 0, + "kind": "open", + "argument": "6034458062541543431" + }, + { + "index": 1, + "kind": "render-preflight", + "argument": "11557984931371038396" + }, + { + "index": 2, + "kind": "cancel", + "argument": "15318040145324592085" + }, + { + "index": 3, + "kind": "replace-revision", + "argument": "10503351105320755683" + }, + { + "index": 4, + "kind": "save-reopen", + "argument": "8670814518977129462" + }, + { + "index": 5, + "kind": "rollback", + "argument": "2056252673123062811" + }, + { + "index": 6, + "kind": "rollback", + "argument": "8796674579583408103" + }, + { + "index": 7, + "kind": "cancel", + "argument": "7529703556190505138" + }, + { + "index": 8, + "kind": "save-reopen", + "argument": "3498278710874659391" + }, + { + "index": 9, + "kind": "cancel", + "argument": "248165894482323742" + }, + { + "index": 10, + "kind": "render-preflight", + "argument": "8409758025133656170" + }, + { + "index": 11, + "kind": "replace-revision", + "argument": "13092916002760833247" + }, + { + "index": 12, + "kind": "rollback", + "argument": "16280336030479743977" + }, + { + "index": 13, + "kind": "cancel", + "argument": "7836534050970960017" + }, + { + "index": 14, + "kind": "rollback", + "argument": "13865125085764645207" + }, + { + "index": 15, + "kind": "render-preflight", + "argument": "16783752467326883490" + }, + { + "index": 16, + "kind": "open", + "argument": "6438395466543381809" + }, + { + "index": 17, + "kind": "cancel", + "argument": "3076831087101073067" + }, + { + "index": 18, + "kind": "render-preflight", + "argument": "4370711324695542987" + }, + { + "index": 19, + "kind": "rollback", + "argument": "3509485308483665705" + }, + { + "index": 20, + "kind": "cancel", + "argument": "16165119337040266006" + }, + { + "index": 21, + "kind": "cancel", + "argument": "7224592201735533095" + }, + { + "index": 22, + "kind": "rollback", + "argument": "13014678599744179864" + }, + { + "index": 23, + "kind": "replace-revision", + "argument": "1665540552503060410" + }, + { + "index": 24, + "kind": "replace-revision", + "argument": "6306584685703776445" + }, + { + "index": 25, + "kind": "cancel", + "argument": "12331126889054291033" + }, + { + "index": 26, + "kind": "rollback", + "argument": "15788316041716919364" + }, + { + "index": 27, + "kind": "render-preflight", + "argument": "15279607423935238490" + }, + { + "index": 28, + "kind": "render-preflight", + "argument": "4289324551056899125" + }, + { + "index": 29, + "kind": "replace-revision", + "argument": "1589285450794043930" + }, + { + "index": 30, + "kind": "render-preflight", + "argument": "13188742041876043665" + }, + { + "index": 31, + "kind": "render-preflight", + "argument": "8990961894696766626" + }, + { + "index": 32, + "kind": "save-reopen", + "argument": "17571388497116038286" + }, + { + "index": 33, + "kind": "cancel", + "argument": "1800772835607805823" + }, + { + "index": 34, + "kind": "render-preflight", + "argument": "7726785530441472681" + }, + { + "index": 35, + "kind": "save-reopen", + "argument": "9912815472792896467" + }, + { + "index": 36, + "kind": "cancel", + "argument": "16278907643064479574" + }, + { + "index": 37, + "kind": "render-preflight", + "argument": "11900770073273755717" + }, + { + "index": 38, + "kind": "open", + "argument": "15479172974279900518" + }, + { + "index": 39, + "kind": "render-preflight", + "argument": "17526234272234945023" + }, + { + "index": 40, + "kind": "replace-revision", + "argument": "7482245642670188713" + }, + { + "index": 41, + "kind": "cancel", + "argument": "3593688926539614798" + }, + { + "index": 42, + "kind": "rollback", + "argument": "11034052604634052349" + }, + { + "index": 43, + "kind": "rollback", + "argument": "17790316739075330643" + }, + { + "index": 44, + "kind": "rollback", + "argument": "9530928495526821296" + }, + { + "index": 45, + "kind": "save-reopen", + "argument": "16668919129355485356" + }, + { + "index": 46, + "kind": "cancel", + "argument": "15901814786715132587" + }, + { + "index": 47, + "kind": "replace-revision", + "argument": "7191992002674859755" + }, + { + "index": 48, + "kind": "replace-revision", + "argument": "1883587719972258900" + }, + { + "index": 49, + "kind": "rollback", + "argument": "8769943291277525666" + }, + { + "index": 50, + "kind": "open", + "argument": "5761950815985536249" + }, + { + "index": 51, + "kind": "open", + "argument": "13922630760902466604" + }, + { + "index": 52, + "kind": "replace-revision", + "argument": "15821748741709964980" + }, + { + "index": 53, + "kind": "replace-revision", + "argument": "11564848330980856789" + }, + { + "index": 54, + "kind": "open", + "argument": "4026995906742413101" + }, + { + "index": 55, + "kind": "save-reopen", + "argument": "5722345623930212980" + }, + { + "index": 56, + "kind": "open", + "argument": "17101291144489736303" + }, + { + "index": 57, + "kind": "cancel", + "argument": "9963324368094161009" + }, + { + "index": 58, + "kind": "cancel", + "argument": "3511687468419819300" + }, + { + "index": 59, + "kind": "rollback", + "argument": "4887354383063994991" + }, + { + "index": 60, + "kind": "open", + "argument": "6360821787086858277" + }, + { + "index": 61, + "kind": "replace-revision", + "argument": "6165075258081952870" + }, + { + "index": 62, + "kind": "open", + "argument": "17594608256850659803" + }, + { + "index": 63, + "kind": "close", + "argument": "8678437679157409518" + } + ], + "expected_invariants": [ + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only" + ], + "observed_result": "invariants-held", + "shrink_history": [] +} diff --git a/UnitTests/testdata/lifecycle/seed-20260906.json b/UnitTests/testdata/lifecycle/seed-20260906.json new file mode 100644 index 000000000..a47f7f624 --- /dev/null +++ b/UnitTests/testdata/lifecycle/seed-20260906.json @@ -0,0 +1,336 @@ +{ + "schema_kind": "loop-lifecycle-trace", + "schema_version": 1, + "seed": 539363590, + "initial_artifact_digest": "161a1beb41c008069762349ccae021ac2276deb2314aed2cdcb746a485f4dbcf", + "commands": [ + { + "index": 0, + "kind": "open", + "argument": "14230604003345983359" + }, + { + "index": 1, + "kind": "render-preflight", + "argument": "1588026375595219817" + }, + { + "index": 2, + "kind": "cancel", + "argument": "16797393914780404380" + }, + { + "index": 3, + "kind": "replace-revision", + "argument": "10487858659920104814" + }, + { + "index": 4, + "kind": "save-reopen", + "argument": "8777801630590435565" + }, + { + "index": 5, + "kind": "rollback", + "argument": "10478246686685788487" + }, + { + "index": 6, + "kind": "replace-revision", + "argument": "8667998413670437203" + }, + { + "index": 7, + "kind": "rollback", + "argument": "13146596628838623446" + }, + { + "index": 8, + "kind": "replace-revision", + "argument": "1096037477704783486" + }, + { + "index": 9, + "kind": "rollback", + "argument": "12321288239470446300" + }, + { + "index": 10, + "kind": "save-reopen", + "argument": "16576694838748259565" + }, + { + "index": 11, + "kind": "rollback", + "argument": "1128805223659789048" + }, + { + "index": 12, + "kind": "replace-revision", + "argument": "3557646355500177479" + }, + { + "index": 13, + "kind": "render-preflight", + "argument": "16303864045696106717" + }, + { + "index": 14, + "kind": "replace-revision", + "argument": "11011505654670955743" + }, + { + "index": 15, + "kind": "replace-revision", + "argument": "6747742078117334680" + }, + { + "index": 16, + "kind": "open", + "argument": "13757629247951878051" + }, + { + "index": 17, + "kind": "save-reopen", + "argument": "15410281890512757605" + }, + { + "index": 18, + "kind": "cancel", + "argument": "12613518827303110357" + }, + { + "index": 19, + "kind": "open", + "argument": "3662771228197864827" + }, + { + "index": 20, + "kind": "open", + "argument": "15371419352448802939" + }, + { + "index": 21, + "kind": "render-preflight", + "argument": "2742528321361811816" + }, + { + "index": 22, + "kind": "replace-revision", + "argument": "16881758039184367644" + }, + { + "index": 23, + "kind": "replace-revision", + "argument": "7515271925486513300" + }, + { + "index": 24, + "kind": "cancel", + "argument": "11110460629007302314" + }, + { + "index": 25, + "kind": "render-preflight", + "argument": "7180552353081869726" + }, + { + "index": 26, + "kind": "replace-revision", + "argument": "16764869896933110734" + }, + { + "index": 27, + "kind": "rollback", + "argument": "7397236548083670144" + }, + { + "index": 28, + "kind": "open", + "argument": "4472030064572726324" + }, + { + "index": 29, + "kind": "cancel", + "argument": "14395696145476499051" + }, + { + "index": 30, + "kind": "render-preflight", + "argument": "10022073564175744054" + }, + { + "index": 31, + "kind": "render-preflight", + "argument": "6854377914811902404" + }, + { + "index": 32, + "kind": "replace-revision", + "argument": "8208756044759640812" + }, + { + "index": 33, + "kind": "cancel", + "argument": "13946846204961241087" + }, + { + "index": 34, + "kind": "cancel", + "argument": "16381313552732617312" + }, + { + "index": 35, + "kind": "replace-revision", + "argument": "5100374929593666101" + }, + { + "index": 36, + "kind": "open", + "argument": "13722538589771077530" + }, + { + "index": 37, + "kind": "save-reopen", + "argument": "6905591012119966347" + }, + { + "index": 38, + "kind": "replace-revision", + "argument": "8951784006284481126" + }, + { + "index": 39, + "kind": "rollback", + "argument": "5746369506577447091" + }, + { + "index": 40, + "kind": "cancel", + "argument": "12809106175243204014" + }, + { + "index": 41, + "kind": "replace-revision", + "argument": "17148102063643212214" + }, + { + "index": 42, + "kind": "open", + "argument": "8385593886831664121" + }, + { + "index": 43, + "kind": "render-preflight", + "argument": "9157938091105991117" + }, + { + "index": 44, + "kind": "replace-revision", + "argument": "17112200312213409498" + }, + { + "index": 45, + "kind": "render-preflight", + "argument": "7375059468494641751" + }, + { + "index": 46, + "kind": "save-reopen", + "argument": "4282029775557665062" + }, + { + "index": 47, + "kind": "cancel", + "argument": "9638691280653838860" + }, + { + "index": 48, + "kind": "cancel", + "argument": "12993024188932119725" + }, + { + "index": 49, + "kind": "rollback", + "argument": "7500018228949373022" + }, + { + "index": 50, + "kind": "rollback", + "argument": "15331566196732507261" + }, + { + "index": 51, + "kind": "rollback", + "argument": "6657391704554243592" + }, + { + "index": 52, + "kind": "replace-revision", + "argument": "13324463767618875400" + }, + { + "index": 53, + "kind": "open", + "argument": "12671486952690650551" + }, + { + "index": 54, + "kind": "save-reopen", + "argument": "4709526536834903823" + }, + { + "index": 55, + "kind": "render-preflight", + "argument": "15059432513886933886" + }, + { + "index": 56, + "kind": "rollback", + "argument": "3420096264905400964" + }, + { + "index": 57, + "kind": "rollback", + "argument": "8902507161373401593" + }, + { + "index": 58, + "kind": "rollback", + "argument": "17518160122279735234" + }, + { + "index": 59, + "kind": "replace-revision", + "argument": "2758098344497143889" + }, + { + "index": 60, + "kind": "rollback", + "argument": "4378049450544246792" + }, + { + "index": 61, + "kind": "replace-revision", + "argument": "12750877198838246339" + }, + { + "index": 62, + "kind": "render-preflight", + "argument": "5156819502440901355" + }, + { + "index": 63, + "kind": "close", + "argument": "13189511042334219285" + } + ], + "expected_invariants": [ + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only" + ], + "observed_result": "invariants-held", + "shrink_history": [] +} diff --git a/UnitTests/testdata/lifecycle/seed-20261201.json b/UnitTests/testdata/lifecycle/seed-20261201.json new file mode 100644 index 000000000..7869bc5bc --- /dev/null +++ b/UnitTests/testdata/lifecycle/seed-20261201.json @@ -0,0 +1,336 @@ +{ + "schema_kind": "loop-lifecycle-trace", + "schema_version": 1, + "seed": 539365889, + "initial_artifact_digest": "161a1beb41c008069762349ccae021ac2276deb2314aed2cdcb746a485f4dbcf", + "commands": [ + { + "index": 0, + "kind": "open", + "argument": "16054040628713633634" + }, + { + "index": 1, + "kind": "render-preflight", + "argument": "13709248772244232181" + }, + { + "index": 2, + "kind": "cancel", + "argument": "15061211214994535425" + }, + { + "index": 3, + "kind": "replace-revision", + "argument": "6033347758867561771" + }, + { + "index": 4, + "kind": "save-reopen", + "argument": "14743479986073434988" + }, + { + "index": 5, + "kind": "rollback", + "argument": "10370917642072392039" + }, + { + "index": 6, + "kind": "save-reopen", + "argument": "6972387814645228182" + }, + { + "index": 7, + "kind": "rollback", + "argument": "15464864822259309587" + }, + { + "index": 8, + "kind": "open", + "argument": "773992352547029268" + }, + { + "index": 9, + "kind": "save-reopen", + "argument": "9707748779095730431" + }, + { + "index": 10, + "kind": "rollback", + "argument": "9496772363638990753" + }, + { + "index": 11, + "kind": "open", + "argument": "2214694760053319153" + }, + { + "index": 12, + "kind": "cancel", + "argument": "14030004003817890535" + }, + { + "index": 13, + "kind": "rollback", + "argument": "7583470035862913580" + }, + { + "index": 14, + "kind": "render-preflight", + "argument": "12074258694667358269" + }, + { + "index": 15, + "kind": "open", + "argument": "13761973405340247722" + }, + { + "index": 16, + "kind": "save-reopen", + "argument": "5171938990575051653" + }, + { + "index": 17, + "kind": "rollback", + "argument": "2345211413140230101" + }, + { + "index": 18, + "kind": "save-reopen", + "argument": "1628523258763850760" + }, + { + "index": 19, + "kind": "render-preflight", + "argument": "5038585513152486872" + }, + { + "index": 20, + "kind": "render-preflight", + "argument": "253354491755752116" + }, + { + "index": 21, + "kind": "render-preflight", + "argument": "7229688035849407956" + }, + { + "index": 22, + "kind": "save-reopen", + "argument": "13044934373465555695" + }, + { + "index": 23, + "kind": "rollback", + "argument": "10895625138792824209" + }, + { + "index": 24, + "kind": "rollback", + "argument": "18260822582291749403" + }, + { + "index": 25, + "kind": "rollback", + "argument": "2168527590852211366" + }, + { + "index": 26, + "kind": "save-reopen", + "argument": "668363155651112498" + }, + { + "index": 27, + "kind": "open", + "argument": "12560823782655447857" + }, + { + "index": 28, + "kind": "rollback", + "argument": "7363036765673601358" + }, + { + "index": 29, + "kind": "open", + "argument": "6489798371295961759" + }, + { + "index": 30, + "kind": "open", + "argument": "9462842297514227416" + }, + { + "index": 31, + "kind": "render-preflight", + "argument": "14844150999868734056" + }, + { + "index": 32, + "kind": "render-preflight", + "argument": "16421023710682210765" + }, + { + "index": 33, + "kind": "cancel", + "argument": "1544723242647003896" + }, + { + "index": 34, + "kind": "cancel", + "argument": "2854918988201860765" + }, + { + "index": 35, + "kind": "open", + "argument": "5988409256948600677" + }, + { + "index": 36, + "kind": "cancel", + "argument": "10642396680245204812" + }, + { + "index": 37, + "kind": "open", + "argument": "8794761475480588469" + }, + { + "index": 38, + "kind": "replace-revision", + "argument": "11945211581858129974" + }, + { + "index": 39, + "kind": "save-reopen", + "argument": "9658355568562264202" + }, + { + "index": 40, + "kind": "cancel", + "argument": "1218796795764106474" + }, + { + "index": 41, + "kind": "open", + "argument": "2130223485059635384" + }, + { + "index": 42, + "kind": "replace-revision", + "argument": "8587225387843416840" + }, + { + "index": 43, + "kind": "save-reopen", + "argument": "10591484320609480747" + }, + { + "index": 44, + "kind": "render-preflight", + "argument": "4201101714001588171" + }, + { + "index": 45, + "kind": "replace-revision", + "argument": "1167964152539473042" + }, + { + "index": 46, + "kind": "render-preflight", + "argument": "5954331234777416219" + }, + { + "index": 47, + "kind": "open", + "argument": "15264214914524986704" + }, + { + "index": 48, + "kind": "render-preflight", + "argument": "13625014935478822327" + }, + { + "index": 49, + "kind": "save-reopen", + "argument": "3124866179075240274" + }, + { + "index": 50, + "kind": "open", + "argument": "10702406781575412905" + }, + { + "index": 51, + "kind": "rollback", + "argument": "4749815308794565093" + }, + { + "index": 52, + "kind": "render-preflight", + "argument": "2325484016830293738" + }, + { + "index": 53, + "kind": "render-preflight", + "argument": "14778039963435660010" + }, + { + "index": 54, + "kind": "rollback", + "argument": "6018898021606890206" + }, + { + "index": 55, + "kind": "replace-revision", + "argument": "14574415953043251551" + }, + { + "index": 56, + "kind": "render-preflight", + "argument": "17940381580291829945" + }, + { + "index": 57, + "kind": "open", + "argument": "13517690949233321384" + }, + { + "index": 58, + "kind": "replace-revision", + "argument": "8630776572484843022" + }, + { + "index": 59, + "kind": "save-reopen", + "argument": "10155335922637887513" + }, + { + "index": 60, + "kind": "open", + "argument": "899533631255863596" + }, + { + "index": 61, + "kind": "open", + "argument": "3615294935064304551" + }, + { + "index": 62, + "kind": "replace-revision", + "argument": "1533225401934010033" + }, + { + "index": 63, + "kind": "close", + "argument": "11231086953340464765" + } + ], + "expected_invariants": [ + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only" + ], + "observed_result": "invariants-held", + "shrink_history": [] +} diff --git a/UnitTests/tst_lifecycletest.cpp b/UnitTests/tst_lifecycletest.cpp index 598913422..2f1f0a99c 100644 --- a/UnitTests/tst_lifecycletest.cpp +++ b/UnitTests/tst_lifecycletest.cpp @@ -21,6 +21,7 @@ // SOFTWARE. #include "pdfartifactstore.h" +#include "pdfapplicationidentity.h" #include "pdfdocumentbuilder.h" #include "pdfdocumentcontext.h" #include "pdfjobscheduler.h" @@ -33,9 +34,12 @@ #include #include #include +#include #include #include +#include #include +#include #include #include @@ -49,7 +53,14 @@ class LifecycleTest : public QObject Q_OBJECT private slots: + void initTestCase(); void boundedTraceGenerationIsDeterministic(); + void qualificationCorpusSchemasAreValid(); + void qualificationCorpusSeedsMatchGoldenTraces(); + void qualificationCorpusReplayPreservesInvariants(); + void deltaDebugShrinkPreservesFailure(); + void promotedFailureTracesMatchExpectedViolations(); + void crossPlatformCorpusReportIsStable(); void seededSequencePreservesInvariants(); void injectedStaleResultIsCaught(); void injectedOverwriteIsCaught(); @@ -59,6 +70,26 @@ private slots: namespace { +constexpr int kMaxTraceCommands = 64; +constexpr quint64 kPrimarySeed = UINT64_C(0x20260821); + +const QStringList kExpectedInvariants = { + QStringLiteral("source-immutable"), + QStringLiteral("cancel-is-terminal"), + QStringLiteral("stale-results-rejected"), + QStringLiteral("history-append-only"), +}; + +const QStringList kAllowedCommandKinds = { + QStringLiteral("open"), + QStringLiteral("render-preflight"), + QStringLiteral("cancel"), + QStringLiteral("replace-revision"), + QStringLiteral("save-reopen"), + QStringLiteral("rollback"), + QStringLiteral("close"), +}; + enum class TraceCommandKind { Open, @@ -70,6 +101,14 @@ enum class TraceCommandKind Close, }; +enum class TraceReplayProfile +{ + None, + InjectStaleAcceptance, + InjectSourceOverwrite, + InjectHistoryMutation, +}; + QString traceCommandName(TraceCommandKind kind) { switch (kind) @@ -92,6 +131,39 @@ QString traceCommandName(TraceCommandKind kind) return QStringLiteral("unknown"); } +std::optional traceCommandKindFromName(const QString& name) +{ + if (name == QStringLiteral("open")) + { + return TraceCommandKind::Open; + } + if (name == QStringLiteral("render-preflight")) + { + return TraceCommandKind::RenderPreflight; + } + if (name == QStringLiteral("cancel")) + { + return TraceCommandKind::Cancel; + } + if (name == QStringLiteral("replace-revision")) + { + return TraceCommandKind::ReplaceRevision; + } + if (name == QStringLiteral("save-reopen")) + { + return TraceCommandKind::SaveReopen; + } + if (name == QStringLiteral("rollback")) + { + return TraceCommandKind::Rollback; + } + if (name == QStringLiteral("close")) + { + return TraceCommandKind::Close; + } + return std::nullopt; +} + struct TraceCommand { TraceCommandKind kind; @@ -107,7 +179,7 @@ quint64 nextTraceRandom(quint64& state) return value ^ (value >> 31); } -QVector generateTrace(quint64 seed) +QVector generateTrace(quint64 seed, int maxCommands = kMaxTraceCommands) { const QVector activeCoverage = { TraceCommandKind::Open, @@ -118,13 +190,13 @@ QVector generateTrace(quint64 seed) TraceCommandKind::Rollback, }; QVector trace; - trace.reserve(32); + trace.reserve(maxCommands); quint64 state = seed; for (const TraceCommandKind kind : activeCoverage) { trace.append({ kind, nextTraceRandom(state) }); } - while (trace.size() < 31) + while (trace.size() < maxCommands - 1) { const auto kind = activeCoverage.at(static_cast(nextTraceRandom(state) % activeCoverage.size())); trace.append({ kind, nextTraceRandom(state) }); @@ -133,7 +205,10 @@ QVector generateTrace(quint64 seed) return trace; } -QJsonObject traceToJson(quint64 seed, const QVector& trace) +QJsonObject traceToJson(quint64 seed, + const QVector& trace, + const QString& observedResult, + const QJsonArray& shrinkHistory) { QJsonArray commands; for (qsizetype index = 0; index < trace.size(); ++index) @@ -149,11 +224,9 @@ QJsonObject traceToJson(quint64 seed, const QVector& trace) { QStringLiteral("seed"), static_cast(seed) }, { QStringLiteral("initial_artifact_digest"), pdf::PDFRunIdentity::digestBytes(QByteArrayLiteral("lifecycle-source-v1")) }, { QStringLiteral("commands"), commands }, - { QStringLiteral("expected_invariants"), QJsonArray{ - QStringLiteral("source-immutable"), - QStringLiteral("cancel-is-terminal"), - QStringLiteral("stale-results-rejected"), - QStringLiteral("history-append-only") } } + { QStringLiteral("expected_invariants"), QJsonArray::fromStringList(kExpectedInvariants) }, + { QStringLiteral("observed_result"), observedResult }, + { QStringLiteral("shrink_history"), shrinkHistory }, }; } @@ -259,58 +332,672 @@ bool appendEvent(pdf::PDFOperationHistoryStore& history, return true; } -} // namespace +struct ReplayEnvironment +{ + QTemporaryDir temporary; + pdf::PDFArtifactStore artifacts; + pdf::PDFOperationHistoryStore history; + pdf::PDFDocumentBuilder builder; + pdf::PDFDocument document; + pdf::PDFDocumentContext context; + pdf::PDFJobScheduler scheduler; + LifecycleState state; + QString activeJobId; + TraceReplayProfile profile = TraceReplayProfile::None; -void LifecycleTest::boundedTraceGenerationIsDeterministic() + ReplayEnvironment() : + artifacts(temporary.path()), + history(QDir(temporary.path()).filePath(QStringLiteral("history.sqlite3"))), + context(&document), + scheduler(1) + { + builder.appendPage(QRectF(0, 0, 100, 100)); + document = builder.build(); + context.setDocument(&document); + } + + bool isValid() const + { + return temporary.isValid(); + } +}; + +bool openDocument(ReplayEnvironment& environment) { - constexpr quint64 seed = UINT64_C(0x20260821); - const QVector first = generateTrace(seed); - const QVector second = generateTrace(seed); - QCOMPARE(first.size(), 32); - QCOMPARE(QJsonDocument(traceToJson(seed, first)).toJson(QJsonDocument::Compact), - QJsonDocument(traceToJson(seed, second)).toJson(QJsonDocument::Compact)); + if (environment.state.open) + { + return true; + } + if (!environment.history.open()) + { + return false; + } + const QByteArray originalBytes("lifecycle-source-v1"); + const auto imported = environment.artifacts.importBytes(originalBytes, + { QStringLiteral("application/pdf"), QStringLiteral("source.pdf") }); + if (!imported.success) + { + return false; + } + if (!environment.history.registerOriginalInput(imported.artifact)) + { + return false; + } + environment.state.original = imported.artifact; + environment.state.current = imported.artifact; + environment.state.sourceDigest = imported.artifact.sha256; + environment.state.open = true; + environment.state.lastRevision = environment.context.getRevision().documentRevision; + QUuid openedId; + if (!appendEvent(environment.history, imported.artifact, pdf::PDFOperationHistoryEventKind::DocumentOpened, + pdf::PDFOperationHistoryStatus::Accepted, &openedId, &environment.state, imported.artifact)) + { + return false; + } + if (environment.profile == TraceReplayProfile::InjectSourceOverwrite) + { + // importBytes publishes artifacts read-only, so re-enable the owner + // write bit before corrupting; a silently failed append would make + // the injected defect vanish on Unix-like hosts. + const QString artifactPath = environment.artifacts.pathFor(imported.artifact); + QFile::setPermissions(artifactPath, + QFileDevice::ReadOwner | QFileDevice::WriteOwner | + QFileDevice::ReadGroup | QFileDevice::ReadOther); + QFile file(artifactPath); + if (file.open(QIODevice::Append)) + { + file.write("overwrite"); + file.close(); + } + environment.state.sourceOverwritten = !environment.artifacts.verify(imported.artifact); + } + return true; +} - QFile goldenFile(QStringLiteral(LOOP_UNITTEST_SOURCE_DIR "/testdata/lifecycle/seed-20260821.json")); - QVERIFY(goldenFile.open(QIODevice::ReadOnly)); - QJsonParseError parseError; - const QJsonDocument goldenDocument = QJsonDocument::fromJson(goldenFile.readAll(), &parseError); - QCOMPARE(parseError.error, QJsonParseError::NoError); - QCOMPARE(QJsonDocument(traceToJson(seed, first)).toJson(QJsonDocument::Compact), - QJsonDocument(goldenDocument.object()).toJson(QJsonDocument::Compact)); +bool startPreflight(ReplayEnvironment& environment) +{ + if (!environment.state.open || !environment.activeJobId.isEmpty()) + { + return true; + } + std::atomic_bool started = false; + pdf::PDFJobSpec spec; + spec.kind = pdf::PDFJobKind::Preflight; + spec.documentRevision = environment.context.getRevision().toString(); + environment.activeJobId = environment.scheduler.submit(spec, [&started](pdf::PDFJobContext& jobContext) + { + started = true; + while (!jobContext.isCancellationRequested()) + { + std::this_thread::yield(); + } }); + for (int attempt = 0; attempt < 100 && !started.load(std::memory_order_acquire); ++attempt) + { + QThread::msleep(1); + } + return started.load(std::memory_order_acquire); +} + +bool cancelPreflight(ReplayEnvironment& environment) +{ + if (environment.activeJobId.isEmpty()) + { + return true; + } + if (!environment.scheduler.cancel(environment.activeJobId)) + { + return false; + } + if (!environment.scheduler.waitForFinished(environment.activeJobId, 1000)) + { + return false; + } + const pdf::PDFJobSnapshot snapshot = environment.scheduler.snapshot(environment.activeJobId); + environment.state.lastCancelled = snapshot.status == pdf::PDFJobStatus::Cancelled; + environment.state.lastSucceeded = snapshot.status == pdf::PDFJobStatus::Succeeded; + QUuid cancelledId; + appendEvent(environment.history, environment.state.current, pdf::PDFOperationHistoryEventKind::PreflightRun, + pdf::PDFOperationHistoryStatus::Cancelled, &cancelledId, &environment.state); + environment.activeJobId.clear(); + return true; +} + +bool replaceRevision(ReplayEnvironment& environment, quint64 argument) +{ + Q_UNUSED(argument); + if (!environment.state.open) + { + return false; + } + const pdf::PDFRevisionIdentity beforeEdit = environment.context.getRevision(); + environment.context.markModified(pdf::PDFModifiedDocument::PageContents); + environment.state.lastRevision = environment.context.getRevision().documentRevision; + return environment.context.getRevision().documentRevision > beforeEdit.documentRevision; +} + +bool saveReopen(ReplayEnvironment& environment, quint64 argument) +{ + if (!environment.state.open) + { + return false; + } + const pdf::PDFOperationSavePolicy policy = (argument % 2 == 0) + ? pdf::PDFOperationSavePolicy::incrementalAppend(QStringLiteral("edit")) + : pdf::PDFOperationSavePolicy::saveAsNewArtifact(QStringLiteral("export")); + environment.state.lastSaveMode = policy.mode; + const QByteArray payload = QByteArray("lifecycle-source-v1-") + QByteArray::number(argument); + const auto saved = environment.artifacts.importBytes(payload, + { QStringLiteral("application/pdf"), QStringLiteral("edited.pdf") }); + if (!saved.success) + { + return false; + } + if (!environment.history.registerArtifact(saved.artifact)) + { + return false; + } + environment.state.current = saved.artifact; + QUuid savedId; + return appendEvent(environment.history, environment.state.original, pdf::PDFOperationHistoryEventKind::FixApplied, + pdf::PDFOperationHistoryStatus::Accepted, &savedId, &environment.state, saved.artifact); +} - bool opened = false; - bool cancelled = false; - quint64 revision = 0; - for (const TraceCommand& command : first) +bool rollbackRevision(ReplayEnvironment& environment, quint64 argument) +{ + Q_UNUSED(argument); + if (!environment.state.open) + { + return false; + } + QUuid rollbackId; + if (!appendEvent(environment.history, environment.state.current, pdf::PDFOperationHistoryEventKind::FixApplied, + pdf::PDFOperationHistoryStatus::RolledBack, &rollbackId, &environment.state, environment.state.current)) + { + return false; + } + const quint64 revisionBeforeRollback = environment.context.getRevision().documentRevision; + environment.context.markModified(pdf::PDFModifiedDocument::PageContents); + environment.state.recovered = true; + environment.state.certified = false; + return environment.context.getRevision().documentRevision > revisionBeforeRollback; +} + +bool closeDocument(ReplayEnvironment& environment) +{ + if (!environment.state.open) { - switch (command.kind) + return true; + } + if (!environment.activeJobId.isEmpty()) + { + if (!cancelPreflight(environment)) { - case TraceCommandKind::Open: - opened = true; - break; - case TraceCommandKind::RenderPreflight: - QVERIFY(opened); - break; - case TraceCommandKind::Cancel: - cancelled = true; - break; - case TraceCommandKind::ReplaceRevision: - QVERIFY(opened); - ++revision; - break; - case TraceCommandKind::SaveReopen: - QVERIFY(opened); - break; - case TraceCommandKind::Rollback: - QVERIFY(opened); - break; - case TraceCommandKind::Close: - opened = false; + return false; + } + } + environment.state.open = false; + return true; +} + +void applyHistoryMutationInjection(ReplayEnvironment& environment) +{ + if (environment.profile != TraceReplayProfile::InjectHistoryMutation) + { + return; + } + if (environment.history.events().size() < 2) + { + return; + } + const int eventCountBefore = environment.history.events().size(); + const QString databasePath = environment.history.databasePath(); + const QString connectionName = QStringLiteral("lifecycle-history-mutate-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); + QSqlDatabase database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connectionName); + database.setDatabaseName(databasePath); + if (!database.open()) + { + return; + } + QSqlQuery query(database); + if (query.exec(QStringLiteral("DELETE FROM history_events WHERE sequence = 1"))) + { + environment.state.historyMutated = environment.history.events().size() < eventCountBefore; + } + database.close(); + database = QSqlDatabase(); + QSqlDatabase::removeDatabase(connectionName); +} + +bool executeTraceCommand(ReplayEnvironment& environment, const TraceCommand& command) +{ + switch (command.kind) + { + case TraceCommandKind::Open: + return openDocument(environment); + case TraceCommandKind::RenderPreflight: + return startPreflight(environment); + case TraceCommandKind::Cancel: + return cancelPreflight(environment); + case TraceCommandKind::ReplaceRevision: + return replaceRevision(environment, command.argument); + case TraceCommandKind::SaveReopen: + return saveReopen(environment, command.argument); + case TraceCommandKind::Rollback: + return rollbackRevision(environment, command.argument); + case TraceCommandKind::Close: + return closeDocument(environment); + } + return false; +} + +QString replayTrace(const QVector& trace, TraceReplayProfile profile = TraceReplayProfile::None) +{ + ReplayEnvironment environment; + if (!environment.isValid()) + { + return QStringLiteral("replay-environment-invalid"); + } + environment.profile = profile; + if (profile == TraceReplayProfile::InjectStaleAcceptance) + { + environment.state.acceptedStale = true; + } + for (const TraceCommand& command : trace) + { + if (!executeTraceCommand(environment, command)) + { + return QStringLiteral("replay-command-failed"); + } + const QString failure = invariantFailure(environment.state, environment.artifacts, environment.history); + if (!failure.isEmpty()) + { + return failure; + } + } + applyHistoryMutationInjection(environment); + return invariantFailure(environment.state, environment.artifacts, environment.history); +} + +struct ShrinkResult +{ + QVector minimized; + QJsonArray shrinkHistory; +}; + +ShrinkResult shrinkTrace(QVector trace, TraceReplayProfile profile, const QString& expectedViolation) +{ + const auto reproduces = [&](const QVector& candidate) + { + return replayTrace(candidate, profile) == expectedViolation; + }; + + ShrinkResult result; + result.shrinkHistory.append(static_cast(trace.size())); + if (!reproduces(trace)) + { + result.minimized = trace; + return result; + } + + bool changed = true; + while (changed) + { + changed = false; + for (int index = 0; index < trace.size(); ++index) + { + QVector candidate = trace; + candidate.removeAt(index); + if (candidate.isEmpty()) + { + continue; + } + if (reproduces(candidate)) + { + trace = candidate; + result.shrinkHistory.append(static_cast(trace.size())); + changed = true; break; + } } } - QVERIFY(cancelled); - QVERIFY(revision > 0); + result.minimized = trace; + return result; +} + +QString lifecycleCorpusDirectory() +{ + return QStringLiteral(LOOP_UNITTEST_SOURCE_DIR "/testdata/lifecycle"); +} + +QString validateTraceSchemaObject(const QJsonObject& object) +{ + if (object.value(QStringLiteral("schema_kind")).toString() != QStringLiteral("loop-lifecycle-trace")) + { + return QStringLiteral("schema_kind must be loop-lifecycle-trace"); + } + if (object.value(QStringLiteral("schema_version")).toInt() != 1) + { + return QStringLiteral("schema_version must be 1"); + } + if (!object.contains(QStringLiteral("seed"))) + { + return QStringLiteral("seed is required"); + } + if (object.value(QStringLiteral("initial_artifact_digest")).toString().isEmpty()) + { + return QStringLiteral("initial_artifact_digest is required"); + } + if (object.value(QStringLiteral("observed_result")).toString().isEmpty()) + { + return QStringLiteral("observed_result is required"); + } + if (!object.contains(QStringLiteral("shrink_history")) || !object.value(QStringLiteral("shrink_history")).isArray()) + { + return QStringLiteral("shrink_history must be an array"); + } + const QJsonArray commands = object.value(QStringLiteral("commands")).toArray(); + if (commands.isEmpty() || commands.size() > kMaxTraceCommands) + { + return QStringLiteral("commands must contain 1..64 entries"); + } + for (int index = 0; index < commands.size(); ++index) + { + const QJsonObject command = commands.at(index).toObject(); + if (command.value(QStringLiteral("index")).toInt() != index) + { + return QStringLiteral("command index mismatch"); + } + if (!kAllowedCommandKinds.contains(command.value(QStringLiteral("kind")).toString())) + { + return QStringLiteral("unknown command kind"); + } + } + const QJsonArray expected = object.value(QStringLiteral("expected_invariants")).toArray(); + for (const QJsonValue& value : expected) + { + if (!kExpectedInvariants.contains(value.toString())) + { + return QStringLiteral("unexpected invariant name"); + } + } + return QString(); +} + +std::optional> commandsFromJsonObject(const QJsonObject& object) +{ + QVector trace; + const QJsonArray commands = object.value(QStringLiteral("commands")).toArray(); + trace.reserve(commands.size()); + for (const QJsonValue& value : commands) + { + const QJsonObject command = value.toObject(); + const std::optional kind = traceCommandKindFromName(command.value(QStringLiteral("kind")).toString()); + if (!kind.has_value()) + { + return std::nullopt; + } + trace.append({ *kind, command.value(QStringLiteral("argument")).toString().toULongLong() }); + } + return trace; +} + +QJsonObject loadJsonObject(const QString& path, QString* error) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + { + *error = QStringLiteral("unable to open %1").arg(path); + return {}; + } + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + *error = QStringLiteral("invalid JSON in %1").arg(path); + return {}; + } + return document.object(); +} + +QJsonArray loadCorpusManifestSeeds() +{ + QString error; + const QJsonObject manifest = loadJsonObject(lifecycleCorpusDirectory() + QStringLiteral("/manifest.json"), &error); + if (!error.isEmpty()) + { + return {}; + } + return manifest.value(QStringLiteral("passing_traces")).toArray(); +} + +QJsonArray loadCorpusManifestFailures() +{ + QString error; + const QJsonObject manifest = loadJsonObject(lifecycleCorpusDirectory() + QStringLiteral("/manifest.json"), &error); + if (!error.isEmpty()) + { + return {}; + } + return manifest.value(QStringLiteral("failure_traces")).toArray(); +} + +TraceReplayProfile profileFromName(const QString& name) +{ + if (name == QStringLiteral("inject-stale-acceptance")) + { + return TraceReplayProfile::InjectStaleAcceptance; + } + if (name == QStringLiteral("inject-source-overwrite")) + { + return TraceReplayProfile::InjectSourceOverwrite; + } + if (name == QStringLiteral("inject-history-mutation")) + { + return TraceReplayProfile::InjectHistoryMutation; + } + return TraceReplayProfile::None; +} + +} // namespace + +void LifecycleTest::initTestCase() +{ + // The Loop identity contract (scripts/ci/check_loop_identity.py) forbids + // direct QCoreApplication identity mutation outside + // LoopLibCore/sources/pdfapplicationidentity.cpp, so tests that need a + // stable QSettings namespace use the sanctioned core entry point instead. + pdf::initializeApplicationIdentity(pdf::PDFApplicationSurface::LoopEditor); +} + +void LifecycleTest::boundedTraceGenerationIsDeterministic() +{ + const QVector first = generateTrace(kPrimarySeed); + const QVector second = generateTrace(kPrimarySeed); + QCOMPARE(first.size(), kMaxTraceCommands); + QCOMPARE(QJsonDocument(traceToJson(kPrimarySeed, first, QStringLiteral("invariants-held"), QJsonArray())).toJson(QJsonDocument::Compact), + QJsonDocument(traceToJson(kPrimarySeed, second, QStringLiteral("invariants-held"), QJsonArray())).toJson(QJsonDocument::Compact)); + + const QString goldenPath = lifecycleCorpusDirectory() + QStringLiteral("/seed-20260821.json"); + QString error; + const QJsonObject goldenObject = loadJsonObject(goldenPath, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + QCOMPARE(QJsonDocument(traceToJson(kPrimarySeed, first, QStringLiteral("invariants-held"), QJsonArray())).toJson(QJsonDocument::Compact), + QJsonDocument(goldenObject).toJson(QJsonDocument::Compact)); +} + +void LifecycleTest::qualificationCorpusSchemasAreValid() +{ + const QJsonArray seeds = loadCorpusManifestSeeds(); + QVERIFY2(!seeds.isEmpty(), "lifecycle corpus manifest must list passing_traces"); + for (const QJsonValue& seedEntry : seeds) + { + const QJsonObject entry = seedEntry.toObject(); + const QString fileName = entry.value(QStringLiteral("trace_file")).toString(); + QVERIFY(!fileName.isEmpty()); + const QString path = lifecycleCorpusDirectory() + QStringLiteral("/") + fileName; + QString error; + const QJsonObject object = loadJsonObject(path, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const QString schemaError = validateTraceSchemaObject(object); + QVERIFY2(schemaError.isEmpty(), qPrintable(QStringLiteral("%1: %2").arg(fileName, schemaError))); + QCOMPARE(object.value(QStringLiteral("commands")).toArray().size(), entry.value(QStringLiteral("command_count")).toInt()); + } + + const QJsonArray failures = loadCorpusManifestFailures(); + for (const QJsonValue& failureEntry : failures) + { + const QJsonObject entry = failureEntry.toObject(); + const QString fileName = entry.value(QStringLiteral("trace_file")).toString(); + const QString path = lifecycleCorpusDirectory() + QStringLiteral("/") + fileName; + QString error; + const QJsonObject object = loadJsonObject(path, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const QString schemaError = validateTraceSchemaObject(object); + QVERIFY2(schemaError.isEmpty(), qPrintable(QStringLiteral("%1: %2").arg(fileName, schemaError))); + } +} + +void LifecycleTest::qualificationCorpusSeedsMatchGoldenTraces() +{ + const QJsonArray seeds = loadCorpusManifestSeeds(); + for (const QJsonValue& seedEntry : seeds) + { + const QJsonObject entry = seedEntry.toObject(); + const quint64 seed = static_cast(entry.value(QStringLiteral("seed")).toVariant().toULongLong()); + const QVector generated = generateTrace(seed); + const QJsonObject generatedObject = traceToJson(seed, generated, QStringLiteral("invariants-held"), QJsonArray()); + const QString path = lifecycleCorpusDirectory() + QStringLiteral("/") + entry.value(QStringLiteral("trace_file")).toString(); + QString error; + const QJsonObject goldenObject = loadJsonObject(path, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + QCOMPARE(QJsonDocument(generatedObject).toJson(QJsonDocument::Compact), + QJsonDocument(goldenObject).toJson(QJsonDocument::Compact)); + } +} + +void LifecycleTest::qualificationCorpusReplayPreservesInvariants() +{ + const QJsonArray seeds = loadCorpusManifestSeeds(); + for (const QJsonValue& seedEntry : seeds) + { + const QJsonObject entry = seedEntry.toObject(); + const QString path = lifecycleCorpusDirectory() + QStringLiteral("/") + entry.value(QStringLiteral("trace_file")).toString(); + QString error; + const QJsonObject object = loadJsonObject(path, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const std::optional> trace = commandsFromJsonObject(object); + QVERIFY(trace.has_value()); + const QString failure = replayTrace(*trace); + QCOMPARE(failure, QString()); + } +} + +void LifecycleTest::deltaDebugShrinkPreservesFailure() +{ + const QVector seedTrace = generateTrace(kPrimarySeed); + const ShrinkResult staleShrink = shrinkTrace(seedTrace, TraceReplayProfile::InjectStaleAcceptance, + QStringLiteral("stale-result-accepted")); + QVERIFY(staleShrink.minimized.size() < seedTrace.size()); + QCOMPARE(replayTrace(staleShrink.minimized, TraceReplayProfile::InjectStaleAcceptance), + QStringLiteral("stale-result-accepted")); + QVERIFY(staleShrink.shrinkHistory.size() >= 2); + + const ShrinkResult overwriteShrink = shrinkTrace(seedTrace, TraceReplayProfile::InjectSourceOverwrite, + QStringLiteral("source-overwritten")); + QVERIFY(overwriteShrink.minimized.size() < seedTrace.size()); + QCOMPARE(replayTrace(overwriteShrink.minimized, TraceReplayProfile::InjectSourceOverwrite), + QStringLiteral("source-overwritten")); + + const ShrinkResult historyShrink = shrinkTrace(seedTrace, TraceReplayProfile::InjectHistoryMutation, + QStringLiteral("rollback-history-mutated")); + QVERIFY(historyShrink.minimized.size() < seedTrace.size()); + QCOMPARE(replayTrace(historyShrink.minimized, TraceReplayProfile::InjectHistoryMutation), + QStringLiteral("rollback-history-mutated")); +} + +void LifecycleTest::promotedFailureTracesMatchExpectedViolations() +{ + const QJsonArray failures = loadCorpusManifestFailures(); + QVERIFY2(!failures.isEmpty(), "promoted failure traces must be listed in manifest.json"); + for (const QJsonValue& failureEntry : failures) + { + const QJsonObject entry = failureEntry.toObject(); + const QString path = lifecycleCorpusDirectory() + QStringLiteral("/") + entry.value(QStringLiteral("trace_file")).toString(); + QString error; + const QJsonObject object = loadJsonObject(path, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const std::optional> trace = commandsFromJsonObject(object); + QVERIFY(trace.has_value()); + const TraceReplayProfile profile = profileFromName(entry.value(QStringLiteral("replay_profile")).toString()); + const QString expected = entry.value(QStringLiteral("expected_violation")).toString(); + QCOMPARE(replayTrace(*trace, profile), expected); + QCOMPARE(object.value(QStringLiteral("observed_result")).toString(), expected); + const QJsonArray shrinkHistory = object.value(QStringLiteral("shrink_history")).toArray(); + QVERIFY2(!shrinkHistory.isEmpty(), "promoted traces must record shrink_history"); + QCOMPARE(shrinkHistory.last().toInt(), static_cast(trace->size())); + } +} + +void LifecycleTest::crossPlatformCorpusReportIsStable() +{ + QJsonArray seedResults; + const QJsonArray seeds = loadCorpusManifestSeeds(); + for (const QJsonValue& seedEntry : seeds) + { + const QJsonObject entry = seedEntry.toObject(); + const QString path = lifecycleCorpusDirectory() + QStringLiteral("/") + entry.value(QStringLiteral("trace_file")).toString(); + QString error; + const QJsonObject object = loadJsonObject(path, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const std::optional> trace = commandsFromJsonObject(object); + QVERIFY(trace.has_value()); + const QString failure = replayTrace(*trace); + seedResults.append(QJsonObject{ + { QStringLiteral("seed"), entry.value(QStringLiteral("seed")) }, + { QStringLiteral("file"), entry.value(QStringLiteral("trace_file")) }, + { QStringLiteral("observed_result"), failure.isEmpty() ? QStringLiteral("invariants-held") : failure }, + { QStringLiteral("passed"), failure.isEmpty() }, + }); + } + + QJsonArray failureResults; + const QJsonArray failures = loadCorpusManifestFailures(); + for (const QJsonValue& failureEntry : failures) + { + const QJsonObject entry = failureEntry.toObject(); + const QString path = lifecycleCorpusDirectory() + QStringLiteral("/") + entry.value(QStringLiteral("trace_file")).toString(); + QString error; + const QJsonObject object = loadJsonObject(path, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + const std::optional> trace = commandsFromJsonObject(object); + QVERIFY(trace.has_value()); + const TraceReplayProfile profile = profileFromName(entry.value(QStringLiteral("replay_profile")).toString()); + const QString observed = replayTrace(*trace, profile); + failureResults.append(QJsonObject{ + { QStringLiteral("file"), entry.value(QStringLiteral("trace_file")) }, + { QStringLiteral("expected_violation"), observed }, + { QStringLiteral("passed"), observed == entry.value(QStringLiteral("expected_violation")).toString() }, + }); + } + + const QJsonObject report{ + { QStringLiteral("schema_kind"), QStringLiteral("loop-lifecycle-corpus-report") }, + { QStringLiteral("schema_version"), 1 }, + { QStringLiteral("platform"), QSysInfo::productType() }, + { QStringLiteral("kernel"), QSysInfo::kernelType() }, + { QStringLiteral("cpu_arch"), QSysInfo::currentCpuArchitecture() }, + { QStringLiteral("seed_results"), seedResults }, + { QStringLiteral("failure_results"), failureResults }, + }; + QVERIFY(!report.value(QStringLiteral("platform")).toString().isEmpty()); + for (const QJsonValue& value : seedResults) + { + QVERIFY(value.toObject().value(QStringLiteral("passed")).toBool()); + } + for (const QJsonValue& value : failureResults) + { + QVERIFY(value.toObject().value(QStringLiteral("passed")).toBool()); + } } void LifecycleTest::seededSequencePreservesInvariants() diff --git a/UnitTests/tst_repairoperationtest.cpp b/UnitTests/tst_repairoperationtest.cpp index b5060409e..ee10a62db 100644 --- a/UnitTests/tst_repairoperationtest.cpp +++ b/UnitTests/tst_repairoperationtest.cpp @@ -188,6 +188,7 @@ void RepairOperationTest::addBleedExpectedChanges_areMeasuredWithoutUnexpectedDi QVERIFY(transaction.plans().first().expectedChanges.metadata); QVERIFY(transaction.plans().first().expectedChanges.pageBoxes); QVERIFY(transaction.plans().first().expectedChanges.pageContent); + QVERIFY(transaction.plans().first().expectedChanges.images); QVERIFY(transaction.apply()); QTemporaryDir directory; diff --git a/UnitTests/tst_repairoperatoracceptance.cpp b/UnitTests/tst_repairoperatoracceptance.cpp index 09823500a..a2217ad03 100644 --- a/UnitTests/tst_repairoperatoracceptance.cpp +++ b/UnitTests/tst_repairoperatoracceptance.cpp @@ -17,6 +17,7 @@ class RepairOperatorAcceptanceTest : public QObject private slots: void initTestCase(); void repairOperation_addBleedIsFailClosedAndAtomic(); + void repairOperation_unicodeAndSpacePaths_addBleedPassesWithoutUnexpectedChange(); private: QString m_defaultProfilePath; @@ -92,6 +93,66 @@ void RepairOperatorAcceptanceTest::repairOperation_addBleedIsFailClosedAndAtomic QVERIFY(!report.value(QStringLiteral("output")).toObject().value(QStringLiteral("sha256")).toString().isEmpty()); } +void RepairOperatorAcceptanceTest::repairOperation_unicodeAndSpacePaths_addBleedPassesWithoutUnexpectedChange() +{ + const QString sourcePdf = operatoracceptance::fixturePath(QStringLiteral("bleed-missing.pdf")); + QVERIFY(QFile::exists(sourcePdf)); + + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + + const QString nestedDir = temporaryDirectory.path() + QStringLiteral("/shop files"); + QVERIFY(QDir().mkpath(nestedDir)); + + const QString targetPdf = nestedDir + QStringLiteral("/café poster.pdf"); + QVERIFY(QFile::copy(sourcePdf, targetPdf)); + QVERIFY(QFile::exists(targetPdf)); + + const QString outputPath = nestedDir + QStringLiteral("/café poster_bleed.pdf"); + const QString reportPath = nestedDir + QStringLiteral("/repair-report.json"); + + QByteArray stdOut; + QByteArray stdErr; + int exitCode = -1; + QVERIFY(operatoracceptance::runPdfTool(m_pdfToolPath, + { QStringLiteral("repair"), + targetPdf, + QStringLiteral("--operation"), + QStringLiteral("add-bleed"), + QStringLiteral("--param"), + QStringLiteral("bleed_mm=3"), + QStringLiteral("--param"), + QStringLiteral("mode=mirror"), + QStringLiteral("--param"), + QStringLiteral("force=true"), + QStringLiteral("--profile"), + m_defaultProfilePath, + QStringLiteral("--output"), + outputPath, + QStringLiteral("--report-file"), + reportPath, + QStringLiteral("--console-format"), + QStringLiteral("json") }, + &stdOut, + &stdErr, + &exitCode)); + QCOMPARE(exitCode, 0); + QVERIFY2(stdErr.trimmed().isEmpty(), qPrintable(QString::fromUtf8(stdErr))); + QVERIFY(QFile::exists(outputPath)); + QVERIFY(QFile::exists(reportPath)); + + QFile reportFile(reportPath); + QVERIFY(reportFile.open(QIODevice::ReadOnly)); + QJsonParseError parseError; + const QJsonDocument reportDocument = QJsonDocument::fromJson(reportFile.readAll(), &parseError); + QCOMPARE(parseError.error, QJsonParseError::NoError); + QVERIFY(reportDocument.isObject()); + const QJsonObject report = reportDocument.object(); + QCOMPARE(report.value(QStringLiteral("status")).toString(), QStringLiteral("passed")); + QCOMPARE(report.value(QStringLiteral("diff")).toObject().value(QStringLiteral("summary")).toObject().value(QStringLiteral("unexpected_structural_changes")).toInt(), + 0); +} + QTEST_GUILESS_MAIN(RepairOperatorAcceptanceTest) #if __has_include("tst_repairoperatoracceptance.moc") diff --git a/agent-policy.json b/agent-policy.json index 855c3068d..0410cb119 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -59,6 +59,7 @@ "UnitTests/tst_budgetcorpustest.cpp", "UnitTests/tst_documentsessiontest.cpp", "UnitTests/tst_incrementalsavetest.cpp", + "UnitTests/tst_lifecycletest.cpp", "UnitTests/tst_overprinttest.cpp", "UnitTests/tst_revisionstresstest.cpp" ], diff --git a/changes/cursor-session-10-trust-qualification.md b/changes/cursor-session-10-trust-qualification.md new file mode 100644 index 000000000..1415cd776 --- /dev/null +++ b/changes/cursor-session-10-trust-qualification.md @@ -0,0 +1,4 @@ +Category: internal +Audience: developers +Breaking-Change: no +Summary: Close Session 10 trust gates by fixing add-bleed repair diff classification, migrating PDFDiff onto PDFJobScheduler, adding real-path repair regressions, and freezing independent-validation evidence scaffolding for T-01 through T-03. Keep Windows_MSI and Linux_AppImage on workflow_dispatch with an exact source SHA so packaging does not run on every push or PR targeting `dev`. diff --git a/changes/cursor-session-11-resource-envelope.md b/changes/cursor-session-11-resource-envelope.md new file mode 100644 index 000000000..2f163863e --- /dev/null +++ b/changes/cursor-session-11-resource-envelope.md @@ -0,0 +1,4 @@ +Category: internal +Audience: developers +Breaking-Change: no +Summary: Freeze Session 11 resource-envelope fixture manifest, fail-closed matrix evidence, budget-exhaustion corpus verification, and R-01 closeout updates; hosted Linux and full external fixtures remain incomplete. diff --git a/changes/cursor-session-12-lifecycle-qualification.md b/changes/cursor-session-12-lifecycle-qualification.md new file mode 100644 index 000000000..0a0f90a48 --- /dev/null +++ b/changes/cursor-session-12-lifecycle-qualification.md @@ -0,0 +1,4 @@ +Category: internal +Audience: developers +Breaking-Change: no +Summary: Promote lifecycle trace generation to a four-seed qualification corpus (max 64 commands), add delta-debugging shrink with promoted failure traces, and freeze Session 12 L-01 evidence. diff --git a/changes/cursor-session-13-package-licensing.md b/changes/cursor-session-13-package-licensing.md new file mode 100644 index 000000000..08f4cb021 --- /dev/null +++ b/changes/cursor-session-13-package-licensing.md @@ -0,0 +1,4 @@ +Category: internal +Audience: Release engineering and maintainers +Breaking-Change: no +Summary: Add final-artifact SBOM and third-party-notices generators, Qt LGPL relink test scripts, Session 13 package-licensing procedure and evidence scaffolding, and wire package workflows to emit licensing artifacts from packaged payloads. diff --git a/changes/dev.md b/changes/dev.md index a82808cf9..5c75fc9bb 100644 --- a/changes/dev.md +++ b/changes/dev.md @@ -1,6 +1,4 @@ -# Loop rebrand - -Category: changed -Audience: Users and developers -Breaking-Change: yes — product, module, target, macro, translation, packaging, and repository path identifiers are now Loop-branded. -Summary: Rebrand repository-owned legacy identifiers and paths to Loop while retaining explicit upstream attribution and the upstream XML namespace compatibility string. +Category: fixed +Audience: maintainers +Breaking-Change: no +Summary: Pair dispatch-only packaging workflow runs by checked-out source SHA via run-name and displayTitle matching in CreateReleaseDraft, embed source_sha in Linux_AppImage run-name, and add hermetic Linux AppImage Qt relink regression coverage. diff --git a/changes/fix-paired-package-qualification-review.md b/changes/fix-paired-package-qualification-review.md new file mode 100644 index 000000000..147fff40b --- /dev/null +++ b/changes/fix-paired-package-qualification-review.md @@ -0,0 +1,4 @@ +Category: internal +Audience: Release engineering and maintainers +Breaking-Change: no +Summary: Run Windows Qt copy/launch evidence inside the MSI lifecycle before uninstall, restore library and environment on failure, support usr/bin installations, and add hermetic package-script and final-AppImage workflow regression coverage. Explicitly identify byte-identical Qt copy evidence. diff --git a/docs/0.2.0-closeout-matrix.md b/docs/0.2.0-closeout-matrix.md index 7c13b1c9b..94b4a277a 100644 --- a/docs/0.2.0-closeout-matrix.md +++ b/docs/0.2.0-closeout-matrix.md @@ -1,8 +1,8 @@ # 0.2.0 closeout matrix -**Status:** Phase 5 Sessions 01–09 terminalized on `cursor/session-09-ledger-closeout`; E-01 awaits hosted Release Gate on a merged SHA +**Status:** Phase 5 Sessions 01–09 on `dev` (PR #535 @ `1f69bdf8…`); Session 13 scaffolding on `dev` (PR #539 @ `ebde8661…`); qualification lanes 10–13 may proceed; E-01 awaits hosted Release Gate **Owner:** 0.2.0 -**Updated:** 2026-09-06 +**Updated:** 2026-09-06 (Session 11 resource-envelope candidate on `cursor/session-11-resource-envelope`) (Session 10 trust qualification candidate on `cursor/session-10-trust-qualification`) This matrix is the working acceptance ledger for the 0.2.0 closeout. It keeps implementation, static, automated, integrated runtime, independent/platform, @@ -17,7 +17,10 @@ own sessions prove them. | Field | Recorded value | | --- | --- | -| Integration line | `origin/dev` @ `e7f7e0c378c98f27d1c136996bdb08ba7bdbaea1` (Session 08 / PR #533) + Session 09 `1c3f9d6d2312045264134f07b4d00e26de2058bf` on `cursor/session-09-ledger-closeout` | +| Integration line (`dev`) | `origin/dev` @ `ebde8661bff037e5cae2d37e3c2e3eae8b2ca6b5` (Session 09 #535 + Session 13 scaffolding #539) | +| Stable merge (Session 09) | `origin/stable` @ `bcd74744affece538258b2a232279f6f5df9ee08` (PR #534, 2026-09-06) | +| Session 09 ledger on `dev` | `1f69bdf8bff037e5cae2d37e3c2e3eae8b2ca6b5` (PR #535 merge) | +| Qualification `candidate_sha` | `ebde8661bff037e5cae2d37e3c2e3eae8b2ca6b5` until a lane merge advances `dev` | | Phase 4 cutover branch | P4-S6–S12 re-qualified on post–Issue-17 graph | | Phase 5 qualified baseline | `e7f7e0c378c98f27d1c136996bdb08ba7bdbaea1` | | Candidate release base | `origin/stable` (refresh before merge promotion) | @@ -31,11 +34,11 @@ own sessions prove them. | A-01 | Candidate identity | Refreshed refs, isolated branch, exact toolchain and status | Recorded on cutover branch | | A-02 | Architecture authority | ADRs, roadmap, CMake intent, and generated catalog agree on Quick-only product | Updated; `LoopEditor` installed as Quick | | A-03 | Closeout mapping | Every requirement maps to implementation, test, platform, package, SHA, and issue evidence | Matrix updated; Phase 5 Sessions 01–09 terminalized | -| T-01 | Trust contract | `add-bleed` real-path reproduction and fix or typed pre-mutation rejection | Open (not Phase 5 scope) | -| T-02 | Async/cancellation | Linux/Windows governed sites, terminal cancellation, stale-result rejection | Open (not Phase 5 scope) | -| T-03 | Independent validation | Independent parser/signature validator and conversion fixture provenance | Open (not Phase 5 scope) | -| R-01 | Resource envelope | 10,000-page/image-heavy/pathological workloads | Partial — `UnitTestsHugeDocumentEnvelope::tenThousandPageDocumentCompilesFirstPageWithoutTouchingTheRest` (2026-08-30) proves the literal 10,000-page open-to-first-view case against `PDFDocumentSession`'s bounded compile cache, on blank synthetic pages. The 500 MB image-heavy and pathological (many spots/transparency groups) fixtures, and the per-fixture RSS/timing measurement matrix issue #242 asks for, are still open and need real fixtures plus a hosted run, not a synthetic in-process test. Not Phase 5 scope | -| L-01 | Lifecycle model | Seeded bounded command traces, replay, shrinking | Open (not Phase 5 scope) | +| T-01 | Trust contract | `add-bleed` real-path reproduction and fix or typed pre-mutation rejection | **Candidate** — `PDFAddBleedRepair` declares `expectedChanges.images`; `UnitTestsRepairOperatorAcceptance` unicode/space-path regression; hosted PdfTool proof pending on merged SHA | +| T-02 | Async/cancellation | Linux/Windows governed sites, terminal cancellation, stale-result rejection | **Candidate** — `PDFDiff` uses `PDFJobScheduler`; `check_unmanaged_async.py` allowlist empty; Linux/Windows CI proof pending on merged SHA | +| T-03 | Independent validation | Independent parser/signature validator and conversion fixture provenance | **Partial** — `docs/evidence/session-10-trust/` manifest + schema-frozen evidence; hosted `qpdf`/`pdfsig`/`verapdf` runs required (`status: incomplete` until then) | +| R-01 | Resource envelope | 10,000-page/image-heavy/pathological workloads | **Partial (Session 11)** — fail-closed manifest/matrix frozen on `6e65be48…` in `docs/evidence/session-11-resource-envelope/` (`disposition: incomplete`). Local Windows strict run: 0 measured, identity commit enforced, `-1` preflight/cancel/recovery not promoted. Missing `image-heavy-500mb`, `ten-thousand-page` (DIV2K), Linux hosted matrix, and candidate-SHA PdfTool rebuild | +| L-01 | Lifecycle model | Seeded bounded command traces, replay, shrinking | **Partial (Session 12)** — four-seed / 64-command corpus with delta-debug shrink and promoted failure traces in `UnitTests/testdata/lifecycle/`; evidence `docs/evidence/session-12-lifecycle/`; hosted Linux+Windows `UnitTestsLifecycle` replay pending on merged SHA | | Q-01 | Interaction boundary | Typed facades, revision/generation-fenced requests, bounded cache/scheduler | Implemented; `verify-interaction-boundary.py` | | Q-02 | Direct canvas | Direct `QQuickItem`, scene-graph lifecycle, fidelity/color, backends | Implemented P4-S5–S6; CI on branch | | Q-03 | Quick product workflow | Open → detect → pinpoint → inspect → understand-state | `UnitTestsProductOperatorLoop` + focused suite on branch | @@ -43,7 +46,7 @@ own sessions prove them. | Q-05 | Interaction regression traces | Replayable scenario corpus, two lanes, and a report that names the first violated contract and the phase responsible | **Partial** — issue #146. The corpus, both schemas, and `scripts/ci/check_interaction_traces.py` are in place and gated in CI (`--corpus-only`, no build). Nine scenarios are tracked; one is marked `blocked_on: gh-488`. The C++ replay harness (`UnitTestsInteractionTraces`), the report writer, and the desktop/GPU present lane are still open, so no verified latency measurement is recorded for this candidate. Not Phase 5 scope | | W-01 | No Widgets on installed editor | Installed `LoopEditor` must not link or ship Widgets | **Closed (static + configure; Phase 5 terminal graph)** — `verify-installed-product-graph.py`, `verify-widgets-free-release-profile.py` (static + configure probe) in CI, package smoke scans; E-01 hosted Release Gate proof still open | | P-01 | Cross-platform/package | Linux/Windows native/software smoke, clean-machine package, QML deployment | **Closed (0.2.0)** — same-SHA pair qualified on `b47c62b2…` via workflows `34050834332`/`34050832684`; inspector + paired comparator green; Linux clean-machine AppImage smoke passed in disposable Ubuntu 24.04 container. Windows Server 2022 pristine-VM run **deferred** to release-hardening (required before 1.0) — non-blocking for 0.2.0. See `docs/SESSION_07_PACKAGE_BOUNDARY.md` | -| P-02 | Supply chain/licensing | SBOM, notices, LGPL relink evidence | Open — `docs/quick-runtime-manifest.json` release_gates (not Phase 5 scope) | +| P-02 | Supply chain/licensing | SBOM, notices, LGPL relink evidence | **Open (Session 13 scaffolding)** — artifact-derived generators in `scripts/ci/generate_package_sbom.py`, `generate_package_third_party_notices.py`, `run_qt_relink_test.*`; evidence `docs/evidence/session-13-package-licensing/` status `incomplete` until hosted package builds on candidate SHA. See `docs/SESSION_13_PACKAGE_LICENSING.md` | | E-01 | Exact-SHA hosted gate | Full Release Gate green on one merged candidate SHA | Open — requires merge + hosted CI (not Phase 5 scope) | | E-02 | Independent audit | No-fix audit passes after implementation is frozen | Open (not Phase 5 scope) | | E-03 | Release promotion | Issues updated from merged evidence, package identity, tag/promotion | Open (not Phase 5 scope) | diff --git a/docs/JOB_SCHEDULER.md b/docs/JOB_SCHEDULER.md index bdc8091f2..d050325a9 100644 --- a/docs/JOB_SCHEDULER.md +++ b/docs/JOB_SCHEDULER.md @@ -74,11 +74,9 @@ surface's typed result and UI lifecycle. A source audit can use the table to reject new unmanaged long-running work and to track the remaining conversions. `scripts/ci/check_unmanaged_async.py` is the CI guard for that boundary. It -currently reports the 13 pre-existing `QtConcurrent::run` call sites above as -known migration debt and fails on any new or multiplied unmanaged launch. The -allowlist is a containment measure, not acceptance of #238; the issue remains -open until those product paths are migrated and cancellation/stale-result -evidence is recorded on both supported desktop platforms. +reports zero legacy product `QtConcurrent::run` call sites after Session 10 +(`PDFDiff` migrated onto `PDFJobScheduler`) and fails on any new or multiplied +unmanaged launch. ## Verification diff --git a/docs/ROADMAP_0.5.0-0.10.0.md b/docs/ROADMAP_0.5.0-0.8.0.md similarity index 91% rename from docs/ROADMAP_0.5.0-0.10.0.md rename to docs/ROADMAP_0.5.0-0.8.0.md index 5bd9dd97f..b0c9d172f 100644 --- a/docs/ROADMAP_0.5.0-0.10.0.md +++ b/docs/ROADMAP_0.5.0-0.8.0.md @@ -1,12 +1,22 @@ -# Loop roadmap extension — 0.5.0 → 0.10.0 +# Loop roadmap extension — 0.5.0 → 0.8.0 (consolidation amendment, 2026-09-06) -> **Status: proposed extension, pending operator acceptance.** +> **Status: amended 2026-09-06 — the canonical Notion Roadmap records the consolidation +> amendment of the same date; this document is its accepted scope decomposition.** > The canonical [Notion Loop Roadmap](https://app.notion.com/p/38f9cb079ddb804a96dbe26b8d86e84f) > owns milestone sequencing and boundaries; per the change-control section of that page, > extending the named release train requires an explicit roadmap amendment. This document > is the scope, design, architecture, and orchestration decomposition for that amendment. > It creates no execution authority by itself: 0.2.0 remains the current milestone, and > 0.5.0 cannot activate before 0.4.0 release acceptance. +> +> **Consolidation amendment (2026-09-06).** The originally proposed six releases +> (0.5.0–0.10.0) are consolidated into four: **0.6.0 absorbs the former 0.7.0** +> (production outcome reconciliation) and **0.7.0 absorbs the former 0.9.0** (workflow +> promotion and verified repeat automation); the former 0.10.0 renumbers to **0.8.0**. +> Ceremony sessions (S00 reconcile openers, vertical-integration sessions, qualification +> lanes) were folded into their owning sessions with sub-issue pointers; nothing was +> dropped. Session maps below retain their original numbering as the scope decomposition; +> the executable GitHub issues carry the folded, resequenced numbering noted per chapter. ## 1. Position in the release train @@ -25,9 +35,9 @@ not recreate or weaken them: | 0.5.0 | Job Spine & Job-Aware Production Context | H3 — job-aware workspace | — (substrate) | | 0.6.0 | Governed Intake — Scanned Specs, Page-Gated OCR & Request-to-JobSpec | H4 — OCR and intake | — (substrate) | | 0.7.0 | Production Outcome Reconciliation & Confirmed-Result Ledger | H5 — production learning | 1 — Observe | -| 0.8.0 | Workflow Memory & Evidence-Backed Recommendations | H5 — production learning | 2 — Recommend, 3 — Draft workflow | -| 0.9.0 | Workflow Promotion & Verified Repeat Automation | H5 — production learning | 4 — Approve, 5 — Run with verification | -| 0.10.0 | Platform Hardening, Extension Ecosystem & 1.0 Readiness | GA preparation | (all stages hardened) | +| 0.6.0 (cont.) | *(former 0.7.0) Production Outcome Reconciliation & Confirmed-Result Ledger* | H5 — production learning | 1 — Observe | +| 0.7.0 | Workflow Memory, Recommendations & Verified Repeat Automation (consolidates the former 0.8.0 + 0.9.0) | H5 — production learning | 2–5 — Recommend, Draft workflow, Approve, Run with verification | +| 0.8.0 | Platform Hardening, Extension Ecosystem & 1.0 Readiness (formerly titled 0.10.0) | GA preparation | all stages hardened | "Platform horizon" refers to the dependency-sequenced horizons H3–H5 in [Loop — Platform Evolution, Workflow Intelligence & Roadmap](https://app.notion.com/p/3b49cb079ddb81cea546c5146054a942); @@ -41,21 +51,22 @@ canonical roadmap is placed exactly once in this train; nothing is silently drop | Thin encrypted job spine (request/job/artwork/output/status) | 0.5.0 | | Broader request-to-JobSpec intake | 0.6.0 | | Page-gated OCR and scanned-specification extraction | 0.6.0 | -| Production/RIP/press event import and outcome matching | 0.7.0 | -| Workflow recommendations from reconciled repeat jobs | 0.8.0 | -| Approved workflow promotion and repeat automation | 0.9.0 | -| Broader plugin/tool ecosystem | 0.10.0 | -| macOS qualification if prioritized | 0.10.0 (explicit go/no-go decision session) | +| Production/RIP/press event import and outcome matching | 0.6.0 (confirmed-result ledger) | +| Workflow recommendations from reconciled repeat jobs | 0.7.0 | +| Approved workflow promotion and repeat automation | 0.7.0 | +| Broader plugin/tool ecosystem | 0.8.0 | +| macOS qualification if prioritized | 0.8.0 (explicit go/no-go decision session) | ### Version-number notes - SemVer 2.0 continues to govern ([VERSIONING.md](VERSIONING.md)). Each milestone above is a feature minor; backward-compatible fixes ride the active minor as patches - (0.5.1, 0.5.2, …). `0.10.0` is the minor after `0.9.0` — minor `10` sorts after `9`; - no major bump is implied. -- While the major version is `0`, minor bumps may still break; 0.10.0's contract-freeze + (0.5.1, 0.5.2, …). The consolidation renumbers the tail so the train runs gapless + 0.5.0 → 0.6.0 → 0.7.0 → 0.8.0; the former 0.7.0/0.9.0/0.10.0 planning titles remain + aliases only. +- While the major version is `0`, minor bumps may still break; 0.8.0's contract-freeze work exists precisely to convert that latitude into the 1.0 compatibility promise. -- 1.0.0 is not one of these milestones. 0.10.0 *exits* with the 1.0 release-candidate +- 1.0.0 is not one of these milestones. 0.8.0 *exits* with the 1.0 release-candidate criteria defined, demonstrated on an exact SHA, and a dossier ready for the operator to open the 1.0.0-rc train. @@ -183,6 +194,8 @@ restated. The 0.5.0+ train adds: ### 4.1 · 0.5.0 — Job Spine & Job-Aware Production Context +**Consolidation note.** Absorbs the former 0.4.0-B plan-compiler track (GitHub #34, #128) and the 0.2.1 job scheduler (#238). Ceremony sessions S00, S11, and S12 below were folded: reconciliation into S01, the vertical and hostile/scale lane into the S13 exit gate (GitHub #404, #416). + **Horizon H3.** The operator loop gains a durable subject: work stops being "a PDF I opened" and becomes "a job I'm producing." @@ -269,9 +282,13 @@ NO-GO. NO-GO means fix or extend the milestone, never ship the weakened spine. --- -### 4.2 · 0.6.0 — Governed Intake: Scanned Specs, Page-Gated OCR & Request-to-JobSpec +### 4.2 · 0.6.0 — Governed Intake & Confirmed Outcomes (absorbs the former 0.7.0) + +**Consolidation note.** Executable on GitHub as sessions S01–S17 under milestone +**0.6.0**: the intake arc below (S01–S10, issues #418–#429) runs first, then the former +0.7.0 outcome arc (§4.3, GitHub S11–S17, issues #431–#441). -**Horizon H4.** Scanned or image-only inputs and unstructured requests become structured, +**Horizon H4 → H5.** Scanned or image-only inputs and unstructured requests become structured, provenance-carrying JobSpec candidates — always operator-confirmed. **Objective.** Live-text-first page classification, page-gated OCR as an evidence @@ -349,7 +366,10 @@ is NO-GO. --- -### 4.3 · 0.7.0 — Production Outcome Reconciliation & Confirmed-Result Ledger +### 4.3 · (former 0.7.0, absorbed into 0.6.0) — Production Outcome Reconciliation & Confirmed-Result Ledger + +**Consolidation note.** Retained verbatim as the scope decomposition for 0.6.0 sessions +S11–S17 (GitHub issues #431–#441, milestone 0.6.0). **Horizon H5, automation stage 1 — Observe.** Loop learns what actually happened on press before it is allowed to suggest anything. @@ -362,10 +382,12 @@ partials — to the job spine. Observe-mode only: record and display, recommend **Value hypothesis.** The job record becomes trustworthy history — "what we actually ran and how it ended" — which operators value directly (job lookup, reprint context) and -which is the sole legal training signal for 0.8.0. Signals: % of outputs with confirmed +which is the sole legal training signal for the recommendation arc (0.7.0). Signals: % of outputs with confirmed outcomes; median time-to-reconcile; review-queue precision. -**Entry gate.** 0.5.0 spine in production use; 0.6.0 accepted. At least one real +**Entry gate.** Mid-milestone arc gate: the 0.5.0 spine is in production use and the +0.6.0 intake arc (S01–S10) is accepted before the outcomes arc (S11–S17) activates. At +least one real production event source (even a manually exported log) identified per pilot deployment — importers are built against measured availability, not imagined APIs. @@ -389,7 +411,7 @@ importers are built against measured availability, not imagined APIs. - **Will not create:** press-vendor API clients as dependencies, automatic confirmation, any influence from unconfirmed events, scheduling/costing analytics. -**Out of scope.** Recommendations (0.8.0), automation (0.9.0), bidirectional press +**Out of scope.** Recommendations and automation (0.7.0), bidirectional press control, cost/wage analytics. **Session map.** @@ -425,7 +447,10 @@ from this milestone is NO-GO. --- -### 4.4 · 0.8.0 — Workflow Memory & Evidence-Backed Recommendations +### 4.4 · 0.7.0 (part 1) — Workflow Memory & Evidence-Backed Recommendations + +**Consolidation note.** Formerly milestone 0.8.0; executable on GitHub as 0.7.0 sessions +S01–S08 (issues #443–#453). **Horizon H5, automation stages 2–3 — Recommend & Draft workflow.** After one verified success, suggest; after repeated consistent successes, assemble a draft for review. @@ -441,9 +466,9 @@ recommendation; accepted recommendations pre-fill the normal 0.3.0 governed path **Value hypothesis.** This is the retention engine: setup time on repeat jobs collapses, and the accept/edit/reject stream is a measurable quality signal. Signals: recommendation accept + accept-with-edit rate; repeat-job setup time; drafts promoted -later in 0.9.0. +later in the automation arc (0.7.0 part 2). -**Entry gate.** 0.7.0 accepted **and** a minimum confirmed-outcome corpus exists (target +**Entry gate.** The 0.6.0 confirmed-outcome ledger accepted **and** a minimum confirmed-outcome corpus exists (target per the platform page's experiment design: a reconciled dataset on the order of 200–500 completed jobs, or the pilot-scaled equivalent recorded in the activation reconcile). If the corpus is too thin, the milestone pauses rather than lowering the evidence bar. @@ -469,7 +494,7 @@ the corpus is too thin, the milestone pauses rather than lowering the evidence b - **Will not create:** opaque ML ranking, cross-customer data pooling, auto-applied recommendations, self-updating drafts. -**Out of scope.** Promotion and any automatic execution (0.9.0); pricing/scheduling +**Out of scope.** Promotion and any automatic execution (0.7.0 part 2); pricing/scheduling suggestions. **Session map.** @@ -506,7 +531,10 @@ bar or hide assumptions. Any auto-execution affordance is NO-GO. --- -### 4.5 · 0.9.0 — Workflow Promotion & Verified Repeat Automation +### 4.5 · 0.7.0 (part 2) — Workflow Promotion & Verified Repeat Automation + +**Consolidation note.** Formerly milestone 0.9.0; executable on GitHub as 0.7.0 sessions +S09–S17 (issues #455–#466). **Horizon H5, automation stages 4–5 — Approve & Run with verification.** The largest authority expansion in Loop's history, and therefore the most heavily gated. @@ -526,7 +554,7 @@ on automation, but not automation with this provenance. Signals: % of matching r jobs run under approved workflows; pause precision (pauses that operators judge warranted); zero unsafe auto-runs — a hard metric, not a hope. -**Entry gate.** 0.8.0 accepted with recommendation quality proven on the evaluation +**Entry gate.** The 0.7.0 recommendation arc (part 1) accepted with recommendation quality proven on the evaluation corpus; promotion-policy decisions locked by ADR before implementation (trigger rules, stop conditions, confidence policy, safe-action tiers — the "decisions to lock" list from the platform vision). @@ -591,7 +619,9 @@ milestone — automation ships late or not at all before it ships unverified. --- -### 4.6 · 0.10.0 — Platform Hardening, Extension Ecosystem & 1.0 Readiness +### 4.6 · 0.8.0 (formerly 0.10.0) — Platform Hardening, Extension Ecosystem & 1.0 Readiness + +**Consolidation note.** Formerly milestone 0.10.0; executable on GitHub as 0.8.0 sessions S01–S12 (issues #468–#479). Absorbs the 0.4.0-A operator-help content (#53, #159 into S10), the SBOM track (#263 into S07), distribution deferrals (#39, #40 into S07; #43 is the S08 go/no-go), and the 0.3.0 application-quality carryover register (#49, #144, #146, #155). **GA preparation.** Freeze what 1.0 will promise, open what the ecosystem needs, and prove the platform at production scale. @@ -609,7 +639,7 @@ in place, extendable by third parties, documented for self-serve onboarding — difference between an impressive tool and a sellable platform. Signals: onboarding completion rate, crash-free session rate, update adoption, first external plugins. -**Entry gate.** 0.9.0 accepted. Open V1-era deferrals re-decided rather than inherited: +**Entry gate.** 0.7.0 accepted. Open V1-era deferrals re-decided rather than inherited: installer signing (deferred post-V1 to the paid-distribution decision), macOS (deferred post-V1), overprint-simulation limitation disclosure posture. @@ -719,6 +749,8 @@ At each milestone's activation (its predecessor's release acceptance): ## 8. References - Canonical roadmap (owns sequencing): https://app.notion.com/p/38f9cb079ddb804a96dbe26b8d86e84f +- Consolidation amendment (2026-09-06): Notion Roadmap "After 0.4.0 — the amended planned + train (0.5.0 → 0.8.0)" section; retired GitHub milestone titles 0.9.0 and 0.10.0. - Orchestration hub: https://app.notion.com/p/3c09cb079ddb80cb9a31ee5dd083739d - 📐 Orchestration template: https://app.notion.com/p/3c39cb079ddb81eebf8dd8948612c365 - Platform evolution & workflow intelligence vision: https://app.notion.com/p/3b49cb079ddb81cea546c5146054a942 diff --git a/docs/SEMANTIC_TRUST_ENGINE_ACCEPTANCE.md b/docs/SEMANTIC_TRUST_ENGINE_ACCEPTANCE.md index 223289b9f..ab1101f07 100644 --- a/docs/SEMANTIC_TRUST_ENGINE_ACCEPTANCE.md +++ b/docs/SEMANTIC_TRUST_ENGINE_ACCEPTANCE.md @@ -9,17 +9,16 @@ one exact merged SHA. Baseline under qualification: `origin/dev` at `20593d70dfddc633ee9d8644659c6d3828a89ef4`. -Implementation evidence commit: `7912493e234f1abad3b90f3b813616aeb9d1fd63` -(topic branch `gh-234`; not merged or tagged). +Session 10 candidate branch: `cursor/session-10-trust-qualification` (record +exact merged SHA in `docs/SESSION_10_HANDOFF.md` after landing). | Issue criterion | Implementation location | Test / audit | Windows evidence | Linux evidence | Exact SHA | Status | | --- | --- | --- | --- | --- | --- | --- | -| #234 canonical reducer; PASS/FAIL/INCOMPLETE/ERROR, waivers, zero-finding budget exhaustion, distinct PdfTool exits | `LoopLibCore/sources/pdfpreflightverdict.h`, `PdfTool/pdftoolpreflight.cpp`, `LoopEditorPlugins/LoopPreflightPlugin/preflightreportmodel.cpp` and report dock | `UnitTestsPreflightVerdict`, `UnitTestsPreflightEngine`, `UnitTestsPreflightPlugin`, `UnitTestsOperatorAcceptance`; direct four-state PdfTool fixture matrix; semantic-trust source audit | 15/15 focused targets green; direct exits/states: pass 0, fail 1, incomplete 8, error 9; waiver/budget cases green | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Open — Windows candidate evidence green; Linux and merged-SHA evidence open | -| #236 one artifact/revision authority; complete revision-bound jobs and stale rejection under concurrent mutation | `LoopLibCore/sources/pdfdocumentcontext.*`, `pdfjobscheduler.*`, cache-key types | `UnitTestsIdentitySeparation`, `UnitTestsDocumentSession`, `UnitTestsJobScheduler`, `UnitTestsRevisionStress`; 64-round concurrent render/preflight/thumbnail/repair-plan stress | 15/15 focused targets green, including 32-round concurrent stale-result rejection | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Open — Windows candidate evidence green; Linux and merged-SHA evidence open | -| #237 one durable provenance chain; seven kinds, tamper detection, rollback append, retention, live PdfTool flows | `LoopLibCore/sources/pdfoperationhistory.*`, `pdfoperationhistorystore.*`, `PdfTool/pdftoolpreflight.cpp`, `pdftoolrepair.cpp`, `pdftooladdbleed.cpp` | `UnitTestsOperationHistory`, `UnitTestsLifecycle`, `UnitTestsOperatorAcceptance::livePdfToolFlows_writeVerifiableProvenance`, independent SQLite probe, provenance source audit | 15/15 focused targets green; live preflight/add-bleed sidecars contain revision/profile/output digests and terminal status; SQLite integrity probe green | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Open — generic `repair --operation add-bleed` still returned `repair.unexpected-change`; Linux and merged-SHA evidence open | -| #238 one scheduler submission boundary; no new unmanaged launches; typed GUI handoff and platform cancellation proof | `LoopLibCore/sources/pdfjobscheduler.*`, `scripts/ci/check_unmanaged_async.py`, CI source-integrity jobs | `UnitTestsJobScheduler`, `UnitTestsWorkloadEnvelope`, unmanaged-async source audit | Scheduler/workload tests and source audit green; audit reports 13 known legacy product `QtConcurrent::run` call sites | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Blocked — product-facing unmanaged launches remain and Windows/Linux cancellation proof is not complete | -| #239 explicit save policy; destructive operations cannot append incrementally; source remains immutable; recovered output not approved | `LoopLibCore/sources/pdfsavepolicy.*`, writer policy integration, repair history | `UnitTestsRepairOperation`, `UnitTestsIncrementalSave`, live repair provenance; independent parser/signature validator required | Save-policy/repair tests green; signed annotation/metadata fixture preserves original signed prefix; no independent PDF parser/signature validator available | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Blocked — independent parser/signature evidence absent | +| #234 canonical reducer; PASS/FAIL/INCOMPLETE/ERROR, waivers, zero-finding budget exhaustion, distinct PdfTool exits | `LoopLibCore/sources/pdfpreflightverdict.h`, `PdfTool/pdftoolpreflight.cpp`, `LoopEditorPlugins/LoopPreflightPlugin/preflightreportmodel.cpp` and report dock | `UnitTestsPreflightVerdict`, `UnitTestsPreflightEngine`, `UnitTestsPreflightPlugin`, `UnitTestsOperatorAcceptance`; direct four-state PdfTool fixture matrix; semantic-trust source audit | Focused targets required on merged SHA | Focused targets required on merged SHA | Session 10 merge SHA | Open — Session 10 does not re-close #234; await merged-SHA CI | +| #236 one artifact/revision authority; complete revision-bound jobs and stale rejection under concurrent mutation | `LoopLibCore/sources/pdfdocumentcontext.*`, `pdfjobscheduler.*`, cache-key types | `UnitTestsIdentitySeparation`, `UnitTestsDocumentSession`, `UnitTestsJobScheduler`, `UnitTestsRevisionStress`; 64-round concurrent render/preflight/thumbnail/repair-plan stress | Focused targets required on merged SHA | Focused targets required on merged SHA | Session 10 merge SHA | Open — Session 10 does not re-close #236; await merged-SHA CI | +| #237 one durable provenance chain; seven kinds, tamper detection, rollback append, retention, live PdfTool flows | `LoopLibCore/sources/pdfoperationhistory.*`, `pdfoperationhistorystore.*`, `PdfTool/pdftoolpreflight.cpp`, `pdftoolrepair.cpp`, `pdftooladdbleed.cpp` | `UnitTestsOperationHistory`, `UnitTestsLifecycle`, `UnitTestsOperatorAcceptance::livePdfToolFlows_writeVerifiableProvenance`, independent SQLite probe, provenance source audit; `UnitTestsRepairOperatorAcceptance` real-path repair | `repair --operation add-bleed` no longer fails `repair.unexpected-change` for bleed-missing + unicode paths once PdfTool tests run on merged SHA | Same repair regression required on merged SHA | Session 10 merge SHA | **T-01 candidate** — code + regressions landed; await merged-SHA PdfTool proof | +| #238 one scheduler submission boundary; no new unmanaged launches; typed GUI handoff and platform cancellation proof | `LoopLibCore/sources/pdfjobscheduler.*`, `LoopLibCore/sources/pdfdiff.cpp`, `scripts/ci/check_unmanaged_async.py`, CI source-integrity jobs | `UnitTestsJobScheduler`, `UnitTestsWorkloadEnvelope`, `UnitTestsLifecycle`, unmanaged-async source audit | Scheduler cancellation + stale-result tests run in CI on Windows | Same scheduler tests run in CI on Linux | Session 10 merge SHA | **T-02 candidate** — `PDFDiff` migrated; unmanaged-async allowlist empty; await merged-SHA CI | +| #239 explicit save policy; destructive operations cannot append incrementally; source remains immutable; recovered output not approved | `LoopLibCore/sources/pdfsavepolicy.*`, writer policy integration, repair history | `UnitTestsRepairOperation`, `UnitTestsIncrementalSave`, live repair provenance; independent parser/signature validator required | Save-policy/repair tests required on merged SHA | Same tests required on merged SHA | Session 10 merge SHA | Open — independent parser/signature evidence tied to T-03 | -The implementation SHA above is a topic-branch candidate, not an exact merged -release SHA. The semantic-trust exit document must not be marked green while -any row above is open or blocked. +The Session 10 merge SHA is not a release-qualified result while T-03 evidence +remains `incomplete` or any row above lacks merged-SHA platform proof. diff --git a/docs/SESSION_09_HANDOFF.md b/docs/SESSION_09_HANDOFF.md index 24430dd92..d2f171898 100644 --- a/docs/SESSION_09_HANDOFF.md +++ b/docs/SESSION_09_HANDOFF.md @@ -41,9 +41,20 @@ Deleted Phase 5 identities remain recorded in `docs/product-surface.json` with (`origin/dev` after Session 08 / PR #533). **Candidate SHA:** `1c3f9d6d2312045264134f07b4d00e26de2058bf` on -`cursor/session-09-ledger-closeout`. Frozen evidence: +`cursor/session-09-ledger-closeout` (branch tip `0c8947c1`). Frozen evidence: `docs/evidence/phase5-terminal-closeout/evidence.json`. +**Stable merge (PR #534):** `bcd74744affece538258b2a232279f6f5df9ee08` on +`origin/stable` (2026-09-06). That merge commit includes additional stable-only +packaging and budget work beyond this ledger-closeout diff; qualification lanes +should not treat it as the Session 09 ledger baseline. + +**Dev integration:** merged via [PR #535](https://github.com/studio-berry/loop/pull/535) @ +`1f69bdf8bff037e5cae2d37e3c2e3eae8b2ca6b5`. Session 13 scaffolding landed on +`dev` via [PR #539](https://github.com/studio-berry/loop/pull/539) @ +`ebde8661bff037e5cae2d37e3c2e3eae8b2ca6b5` (current qualification +`candidate_sha`). + Local verifier stack (clean tracked tree): ``` @@ -79,7 +90,7 @@ session's exact SHA. ### Issue 30 — final Phase 5 validation and freeze (PASS locally) Command output is recorded in `docs/evidence/phase5-terminal-closeout/evidence.json`. -Hosted CI on the Session 09 PR is the remaining hosted record; it is not E-01. +PR #534 hosted CI is complete; that run is not E-01. ## Exit gate diff --git a/docs/SESSION_10_HANDOFF.md b/docs/SESSION_10_HANDOFF.md new file mode 100644 index 000000000..e18390b71 --- /dev/null +++ b/docs/SESSION_10_HANDOFF.md @@ -0,0 +1,74 @@ +# Session 10 — Close trust and independent-validation gates + +## Scope + +Session 10 closes Issues 31–33 on one exact candidate SHA: + +- **T-01:** `repair --operation add-bleed` trust contract on real-path fixtures +- **T-02:** Governed async boundary with terminal cancellation and stale-result proofs +- **T-03:** Independent external validation evidence on Linux and Windows + +Baseline: `origin/dev` @ `6a55130c…` (Session 09 #535 + Session 13 #539 + baseline record #540). + +## Implementation + +### Issue 31 — trust contract repair and async semantics + +- **add-bleed diff classification:** `PDFAddBleedRepair` now declares + `expectedChanges.images` so resource-level bleed artwork edits classify as + expected instead of triggering `repair.unexpected-change`. +- **Real-path repair regression:** `UnitTestsRepairOperatorAcceptance` drives + `repair --operation add-bleed` through unicode and space path segments + (`shop files/café poster.pdf`). +- **Governed async:** `PDFDiff` asynchronous comparison submits through + `PDFJobScheduler` instead of `QtConcurrent::run`; the unmanaged-async audit + allowlist is now empty. + +### Issue 32 — independent external evidence + +- Frozen conversion/qualification manifest: + `docs/evidence/session-10-trust/conversion-fixture-manifest.json` +- Builder: + `scripts/qualification/build_session10_trust_evidence.py` +- Evidence slots (schema-conformant, refreshed on merge SHA): + - `docs/evidence/session-10-trust/independent-validation-windows.json` + - `docs/evidence/session-10-trust/independent-validation-linux.json` + +### Issue 33 — qualify trust gates + +- Closeout matrix T-01–T-03 rows updated in `docs/0.2.0-closeout-matrix.md` +- Acceptance ledger refreshed in `docs/SEMANTIC_TRUST_ENGINE_ACCEPTANCE.md` + +## Verification record + +Run after merge records the authoritative `candidate_sha`. + +| Check | Command / target | Result | +| --- | --- | --- | +| Unmanaged async audit | `python scripts/ci/check_unmanaged_async.py` | Required green | +| Trust contract sources | `python scripts/ci/check_trust_contract_sources.py` | Required green | +| Repair operator acceptance | `UnitTestsRepairOperatorAcceptance` | Requires PdfTool build | +| Repair operation diff | `UnitTestsRepairOperation::addBleedExpectedChanges_areMeasuredWithoutUnexpectedDiff` | Requires build | +| Job scheduler cancellation | `UnitTestsJobScheduler` + `UnitTestsLifecycle` | Requires build; same tests run on Linux and Windows CI | +| Independent validation builder | `python scripts/qualification/build_session10_trust_evidence.py` | Writes platform evidence; exit 0 only when validators pass | +| Agent proof | `python scripts/agent/check-change.py --base origin/dev` | Required green | + +## Gate disposition + +| Gate | Local disposition | Hosted follow-up | +| --- | --- | --- | +| T-01 | Code + regression tests landed | Re-run `UnitTestsRepairOperatorAcceptance` on merged SHA in CI | +| T-02 | Scheduler migration + existing cancellation/stale tests | Confirm Linux + Windows CI green on merged SHA | +| T-03 | Manifest + evidence schema frozen | **Blocked** until hosted runners install `qpdf`, `pdfsig`, and `verapdf` and regenerate both evidence JSON files with `status: passed` | + +## Remaining blockers + +1. **Hosted validators (T-03):** Local qualification host lacks `qpdf`, `pdfsig`, + and `verapdf`. Evidence files are intentionally `incomplete` until a hosted + Linux and Windows run executes + `python scripts/qualification/build_session10_trust_evidence.py` on the exact + merged candidate SHA. +2. **PdfTool integration tests (T-01):** Require a configured build of PdfTool + and focused test targets; not run in this workspace session without configure. +3. **Notion reconciliation (Issue 33):** Manual update of Session 10 / Issues + 31–33 pages after merge. diff --git a/docs/SESSION_11_HANDOFF.md b/docs/SESSION_11_HANDOFF.md new file mode 100644 index 000000000..20cbdc1f8 --- /dev/null +++ b/docs/SESSION_11_HANDOFF.md @@ -0,0 +1,30 @@ +# Session 11 — Prove production resource envelope + +## Scope + +Session 11 closes issue #242 measurement gaps for gate **R-01** on the merged +`dev` candidate SHA recorded at PR merge time, anchored to `origin/dev` @ +`6a55130c…`. + +Fixtures remain outside the repository per #242. + +## Issue 34 — Fixture manifest + +Partial manifest with digests for office-2mb, pathological-vector, transparency-spots. +Requirement catalog documents all issue #242 IDs. External bytes under +`C:\.dev\qualification\session-11\fixtures\`. + +## Issue 35 — Measure envelopes + +Local Windows `run_matrix.py --strict`: exit 1, `measured: 0`, fail-closed on stale +PdfTool commit and missing fixtures. `-1` fields never promoted to pass. + +## Issue 36 — Archive + +Frozen `docs/evidence/session-11-resource-envelope/evidence.json` with +`disposition: incomplete`. CI validates via +`scripts/qualification/validate_resource_envelope_evidence.py`. + +## Exit gate + +R-01 remains **Partial** until hosted Linux + full fixture bundle on candidate-SHA PdfTool. diff --git a/docs/SESSION_12_HANDOFF.md b/docs/SESSION_12_HANDOFF.md new file mode 100644 index 000000000..d18659b0c --- /dev/null +++ b/docs/SESSION_12_HANDOFF.md @@ -0,0 +1,25 @@ +# Session 12 — Prove lifecycle model + +## Candidate identity + +| Field | Value | +| --- | --- | +| Branch | `cursor/session-12-lifecycle-qualification` | +| Baseline | `origin/dev` @ `6a55130c…` | +| Evidence | `docs/evidence/session-12-lifecycle/evidence.json` | + +## Deliverables (Issues 37–39) + +- **Issue 37:** Four-seed / 64-command corpus in `UnitTests/testdata/lifecycle/` with `manifest.json`, schema fields `observed_result` + `shrink_history`, `UnitTestsLifecycle` replay tests, `scripts/ci/check_lifecycle_corpus.py`. +- **Issue 38:** Delta-debugging `shrinkTrace()` plus three promoted minimized failure traces (`failure-*-minimized.json`). +- **Issue 39:** `crossPlatformCorpusReportIsStable` and frozen evidence; hosted Linux replay on merge SHA. + +## Verification + +```text +python scripts/ci/check_lifecycle_corpus.py +python scripts/agent/check-change.py --base origin/dev +cmake --build build --target UnitTestsLifecycle && ctest -R UnitTestsLifecycle --output-on-failure +``` + +Corpus static validation passes locally. Full C++ replay requires the vcpkg/Qt toolchain in CI. diff --git a/docs/SESSION_13_HANDOFF.md b/docs/SESSION_13_HANDOFF.md new file mode 100644 index 000000000..df74b5044 --- /dev/null +++ b/docs/SESSION_13_HANDOFF.md @@ -0,0 +1,76 @@ +# Session 13 — package and licensing qualification handoff + +## Scope + +Session 13 implements final-artifact SBOM, third-party notices, LGPL relink +evidence tooling, and the package identity / clean-machine procedure for P-02 +and P-01 SHA re-proof. Session 07 evidence on `b47c62b2…` does **not** +transfer. + +## Baseline + +| Field | Value | +| --- | --- | +| Branch | `dev` (merged PR #539) | +| Qualification `candidate_sha` | `ebde8661bff037e5cae2d37e3c2e3eae8b2ca6b5` | +| Package workflow dispatch | `source_sha=20f73c3a84bbbcb1d45fbcff8bddc90365f636b0` — [Linux run 34068347154](https://github.com/studio-berry/loop/actions/runs/34068347154), [Windows run 34068348127](https://github.com/studio-berry/loop/actions/runs/34068348127) | + +## Implementation + +### Issue 40 — final-artifact SBOM, notices, LGPL evidence + +- `scripts/ci/package_licensing_common.py` — shared component/license mapping +- `scripts/ci/generate_package_sbom.py` — SPDX 2.3 from package-boundary evidence +- `scripts/ci/generate_package_third_party_notices.py` — notices from shipped payload +- `scripts/ci/run_qt_relink_test.sh` / `run_qt_relink_test.ps1` — LGPL relink proofs +- Package workflows extended to emit SBOM, notices, and relink transcripts into + evidence artifacts + +### Issue 41 — package identity and clean-machine lifecycle + +- `docs/SESSION_13_PACKAGE_LICENSING.md` — exact-SHA dispatch, pairing, smoke, and + clean-machine procedure (Ubuntu 24.04 required; Server 2022 deferred) +- Reuses `inspect_package_dependencies.py` and `compare_package_boundary_evidence.py` + +### Issue 42 — evidence freeze and gate bookkeeping + +- `docs/evidence/session-13-package-licensing/` — evidence home (status `incomplete` + until hosted package builds on candidate SHA) +- `scripts/ci/collect_package_licensing_evidence.py` — manifest assembler +- Closeout matrix P-01/P-02 updated; `quick-runtime-manifest.json` tooling pointers + +## Verification record + +Local verifier stack: + +``` +python -m unittest scripts.ci.test_generate_package_licensing -v +python scripts/ci/test_generate_package_licensing.py +python scripts/verify-quick-runtime-contract.py +``` + +## Gate status (honest) + +| Gate | State | Blocker | +| --- | --- | --- | +| P-02 final-artifact SBOM | **Open** | Hosted `Linux_AppImage` + `Windows_MSI` on candidate SHA | +| P-02 third-party notices | **Partial** | Artifact generator implemented; final-artifact proof pending | +| P-02 Qt relink | **Open** | Hosted relink transcripts not yet archived | +| P-01 package identity | **Open** | Must re-prove on candidate SHA (Session 07 `b47c62b2…` invalid) | +| P-01 clean-machine smoke | **Open** | Linux container + Windows hosted MSI smoke on candidate SHA | + +## Hosted package build blockers + +1. **Approval required** per AGENTS.md for hosted packaging workflow dispatch. +2. Dispatch both workflows with `source_sha=` after this branch merges + or from the branch head for qualification. +3. Copy workflow evidence artifacts into `docs/evidence/session-13-package-licensing/` + and run `collect_package_licensing_evidence.py`. +4. Windows Server 2022 pristine VM remains **deferred to 1.0** (non-blocking). + +## Next gate + +After hosted package evidence is frozen with `status: passed`, update +`quick-runtime-manifest.json` release_gates to `complete` and mark P-02 +`acceptance verified` in the closeout matrix. Session 14 requires all lanes +green on the **same** `candidate_sha`. diff --git a/docs/SESSION_13_PACKAGE_LICENSING.md b/docs/SESSION_13_PACKAGE_LICENSING.md new file mode 100644 index 000000000..7ea503b91 --- /dev/null +++ b/docs/SESSION_13_PACKAGE_LICENSING.md @@ -0,0 +1,163 @@ +# Session 13 — package and licensing qualification + +Session 13 closes P-02 (supply chain/licensing) and re-proves P-01 package +identity on the **exact candidate SHA**. Session 07 evidence on +`b47c62b263a3fd7fb36940856866e589bbc8be10` does **not** transfer. + +## Exit gate + +- P-02 complete for exact final artifacts: artifact-derived SBOM, + `THIRD_PARTY_NOTICES.txt`, LGPL relink/replace evidence, and archived + corresponding-source or written-offer record. +- P-01 re-proof on the same candidate SHA via paired package-boundary evidence + and clean-machine smoke (Linux required; Windows Server 2022 pristine VM + remains deferred per Session 07). + +Do not mark gates `complete` without hosted package artifacts built from the +candidate SHA. + +## Candidate SHA discipline + +1. Record the Session 09+ merged `candidate_sha` (40-char lowercase hex). +2. Dispatch **both** package workflows with `source_sha=`: + - `Linux_AppImage` + - `Windows_MSI` +3. Download evidence artifacts and run the pairing/comparator steps below. +4. If any qualification lane lands code after packaging, **re-run Session 13** + on the new SHA before Session 14. + +## Package identity workflow + +Follow `docs/SESSION_07_PACKAGE_BOUNDARY.md` for the inspector contract. +Session 13 adds final-artifact licensing outputs on top of the same boundary +evidence. + +### 1. Dispatch exact-SHA package builds + +```text +workflow: Linux_AppImage +input: source_sha= + +workflow: Windows_MSI +input: source_sha= +``` + +Both workflows verify checkout SHA, record `LOOP_SOURCE_SHA`, run package-boundary +inspection, and (after this session) emit SBOM, notices, and Qt relink +transcripts into the evidence artifact bundle. + +### 2. Pair Linux and Windows boundary evidence + +```text +python3 scripts/ci/compare_package_boundary_evidence.py \ + --linux package-evidence/linux/evidence.json \ + --windows package-evidence/windows/evidence.json \ + --source-sha \ + --output docs/evidence/session-13-package-licensing/paired-evidence.json +``` + +### 3. Generate artifact-derived SBOM and notices + +From each platform's `evidence.json` (final packaged payload, not vcpkg tree): + +```text +python3 scripts/ci/generate_package_sbom.py \ + --evidence package-evidence/linux/evidence.json \ + --output docs/evidence/session-13-package-licensing/linux-components.spdx.json + +python3 scripts/ci/generate_package_third_party_notices.py \ + --evidence package-evidence/linux/evidence.json \ + --output docs/evidence/session-13-package-licensing/linux-THIRD_PARTY_NOTICES.txt +``` + +Repeat for Windows with `windows-evidence.json` and `windows-*` output names. + +### 4. LGPL Qt relink/replace test + +Linux AppImage payload: + +```text +bash scripts/ci/run_qt_relink_test.sh \ + /path/to/Loop-pdf-VERSION-x86_64.AppImage \ + --output docs/evidence/session-13-package-licensing/linux-qt-relink.txt +``` + +Windows installed tree (after MSI install under 64-bit Program Files): + +```powershell +.\scripts\ci\run_qt_relink_test.ps1 ` + -InstallDir "C:\Program Files\LOOP" ` + -SourceSha ` + -OutputPath docs\evidence\session-13-package-licensing\windows-qt-relink.txt +``` + +### 5. Clean-machine smoke + +| Platform | 0.2.0 requirement | Procedure | +| --- | --- | --- | +| Linux | **Required** | Disposable Ubuntu 24.04 container with no Qt/MSVC/Python/dev paths. Run `scripts/smoke-test-appimage.sh --operator`. Archive transcript to `linux-clean-machine-smoke.txt`. | +| Windows hosted MSI | **Required** | `Invoke-MsiSmokeTest.ps1` on the workflow runner against the exact-SHA MSI (packaged launch outside build tree). Evidence uploaded by `Windows_MSI`. | +| Windows Server 2022 pristine VM | **Deferred to 1.0** | Document as known limitation; not a 0.2.0 blocker. | + +Example Linux clean-machine container pattern (from Session 07): + +```text +docker run --rm -v "$PWD:/work" -w /work ubuntu:24.04 bash -lc ' + apt-get update && apt-get install -y libxcb-cursor0 libfontconfig1 libglib2.0-0 libdbus-1-3 + LOOP_SOURCE_SHA= bash scripts/smoke-test-appimage.sh /work/Loop-pdf-*.AppImage --operator +' +``` + +### 6. Freeze Session 13 evidence manifest + +```text +python3 scripts/ci/collect_package_licensing_evidence.py \ + --linux-evidence docs/evidence/session-13-package-licensing/linux-evidence.json \ + --windows-evidence docs/evidence/session-13-package-licensing/windows-evidence.json \ + --source-sha \ + --linux-sbom docs/evidence/session-13-package-licensing/linux-components.spdx.json \ + --linux-notices docs/evidence/session-13-package-licensing/linux-THIRD_PARTY_NOTICES.txt \ + --windows-sbom docs/evidence/session-13-package-licensing/windows-components.spdx.json \ + --windows-notices docs/evidence/session-13-package-licensing/windows-THIRD_PARTY_NOTICES.txt \ + --linux-relink docs/evidence/session-13-package-licensing/linux-qt-relink.txt \ + --windows-relink docs/evidence/session-13-package-licensing/windows-qt-relink.txt \ + --linux-clean-machine docs/evidence/session-13-package-licensing/linux-clean-machine-smoke.txt \ + --output docs/evidence/session-13-package-licensing/evidence.json +``` + +`collect_package_licensing_evidence.py` exits `0` only when `status` is +`passed` (all required artifacts present and boundary evidence passed). + +### 7. Update release gates and closeout matrix + +When `evidence.json` reports `status: passed`: + +- Set `docs/quick-runtime-manifest.json` `release_gates` to `complete` with + evidence pointers (or `partial` until all lanes are green). +- Update `docs/0.2.0-closeout-matrix.md` P-02 to `acceptance verified` and + refresh P-01 SHA binding to ``. + +## Corresponding source / written offer + +Per `docs/PACKAGING_LICENSING.md`, archive the Qt corresponding-source archive +or valid written offer under Berry Studio control. Record the location in the +Session 13 evidence bundle (`written-offer.txt` or equivalent) — not in release +assets. + +## Tooling map + +| Tool | Purpose | +| --- | --- | +| `scripts/ci/inspect_package_dependencies.py` | Final-artifact dependency graph | +| `scripts/ci/compare_package_boundary_evidence.py` | Paired Linux/Windows SHA proof | +| `scripts/ci/generate_package_sbom.py` | SPDX 2.3 SBOM from boundary evidence | +| `scripts/ci/generate_package_third_party_notices.py` | Notices from shipped payload | +| `scripts/ci/run_qt_relink_test.sh` / `.ps1` | LGPL relink evidence | +| `scripts/ci/collect_package_licensing_evidence.py` | Session evidence manifest | +| `scripts/generate-third-party-notices.ps1` | Legacy vcpkg-tree notices (partial only) | + +## Related issues + +- Issue 40 — final-artifact SBOM, notices, LGPL evidence +- Issue 41 — package identity and clean-machine lifecycle +- Issue 42 — close P-01/P-02 with final-artifact evidence diff --git a/docs/evidence/session-10-trust/conversion-fixture-manifest.json b/docs/evidence/session-10-trust/conversion-fixture-manifest.json new file mode 100644 index 000000000..2427f0c45 --- /dev/null +++ b/docs/evidence/session-10-trust/conversion-fixture-manifest.json @@ -0,0 +1,46 @@ +{ + "schema": "loop.session-10-trust-fixtures", + "schema_version": 1, + "candidate_sha_note": "Evidence files must record the exact merged Session 10 candidate SHA.", + "conversion_triad": [ + { + "id": "already-conformant", + "source_manifest": "loop-preflight/testdata/conversion/manifest.json", + "kind": "already-conformant", + "provenance": "Generated at test runtime by UnitTestsStandardOracle using PDFDocumentBuilder; no third-party content.", + "license": "Synthetic test artifact; no third-party license restrictions.", + "digest": "runtime-sha256-recorded-in-independent-evidence", + "expected_validator_result": "passed", + "known_limits": "Structural stand-in; not evidence of every PDF/A or PDF/X feature." + }, + { + "id": "safely-convertible", + "source_manifest": "loop-preflight/testdata/conversion/manifest.json", + "kind": "safely-convertible", + "provenance": "Generated at test runtime by UnitTestsStandardOracle using PDFDocumentBuilder; no third-party content.", + "license": "Synthetic test artifact; no third-party license restrictions.", + "digest": "runtime-sha256-recorded-in-independent-evidence", + "expected_validator_result": "passed", + "known_limits": "Exercises the supported metadata rewrite path; not general PDF/X convertibility." + }, + { + "id": "deliberately-unconvertible", + "source_manifest": "loop-preflight/testdata/conversion/manifest.json", + "kind": "deliberately-unconvertible", + "provenance": "Generated at test runtime by UnitTestsStandardOracle using PDFDocumentBuilder; no third-party content.", + "license": "Synthetic test artifact; no third-party license restrictions.", + "digest": "runtime-sha256-recorded-in-independent-evidence", + "expected_validator_result": "rejected", + "known_limits": "Deliberate blocker fixture; rejection is an oracle contract, not a complete standards corpus." + } + ], + "qualification_pdf": { + "id": "bleed-missing-real-path", + "path": "loop-preflight/testdata/fixtures/bleed-missing.pdf", + "sha256": "09a1f682ab81ed54599bbf708df428cb3d0af77e6884e2855da0c58644740ff1", + "bytes": 674, + "provenance": "Shipped operator-acceptance fixture; missing bleed box used for repair trust-contract qualification.", + "license": "Synthetic test artifact; no third-party license restrictions.", + "renderer_differential_note": "Color/overprint goldens remain covered by gh-360 renderer differential tests; this PDF exercises structural repair output." + } +} diff --git a/docs/evidence/session-10-trust/independent-validation-linux.json b/docs/evidence/session-10-trust/independent-validation-linux.json new file mode 100644 index 000000000..e7d231677 --- /dev/null +++ b/docs/evidence/session-10-trust/independent-validation-linux.json @@ -0,0 +1,42 @@ +{ + "schema": "loop.independent-validation-evidence", + "schema_version": 1, + "status": "incomplete", + "input": { + "path": "loop-preflight/testdata/fixtures/bleed-missing.pdf", + "bytes": 674, + "sha256": "09a1f682ab81ed54599bbf708df428cb3d0af77e6884e2855da0c58644740ff1" + }, + "validators": [ + { + "claim": "structural", + "program": "qpdf", + "program_path": null, + "configured_arguments": ["--check", "{input}"], + "status": "incomplete", + "reason_code": "validator-not-installed" + }, + { + "claim": "signature", + "program": "pdfsig", + "program_path": null, + "configured_arguments": ["{input}"], + "status": "incomplete", + "reason_code": "validator-not-installed" + }, + { + "claim": "standards", + "program": "verapdf", + "program_path": null, + "configured_arguments": ["validate", "--format", "text", "{input}"], + "status": "incomplete", + "reason_code": "validator-not-installed" + } + ], + "platform": { + "system": "Linux", + "release": "pending-hosted-runner", + "machine": "x86_64" + }, + "candidate_sha": "pending-session-10-merge" +} diff --git a/docs/evidence/session-10-trust/independent-validation-windows.json b/docs/evidence/session-10-trust/independent-validation-windows.json new file mode 100644 index 000000000..9313eace8 --- /dev/null +++ b/docs/evidence/session-10-trust/independent-validation-windows.json @@ -0,0 +1,42 @@ +{ + "schema": "loop.independent-validation-evidence", + "schema_version": 1, + "status": "incomplete", + "input": { + "path": "loop-preflight/testdata/fixtures/bleed-missing.pdf", + "bytes": 674, + "sha256": "09a1f682ab81ed54599bbf708df428cb3d0af77e6884e2855da0c58644740ff1" + }, + "validators": [ + { + "claim": "structural", + "program": "qpdf", + "program_path": null, + "configured_arguments": ["--check", "{input}"], + "status": "incomplete", + "reason_code": "validator-not-installed" + }, + { + "claim": "signature", + "program": "pdfsig", + "program_path": null, + "configured_arguments": ["{input}"], + "status": "incomplete", + "reason_code": "validator-not-installed" + }, + { + "claim": "standards", + "program": "verapdf", + "program_path": null, + "configured_arguments": ["validate", "--format", "text", "{input}"], + "status": "incomplete", + "reason_code": "validator-not-installed" + } + ], + "platform": { + "system": "Windows", + "release": "10", + "machine": "AMD64" + }, + "candidate_sha": "pending-session-10-merge" +} diff --git a/docs/evidence/session-11-resource-envelope/evidence.json b/docs/evidence/session-11-resource-envelope/evidence.json new file mode 100644 index 000000000..c73cab651 --- /dev/null +++ b/docs/evidence/session-11-resource-envelope/evidence.json @@ -0,0 +1,40 @@ +{ + "schema_kind": "loop-resource-envelope-qualification-evidence", + "schema_version": 1, + "session": 11, + "branch": "cursor/session-11-resource-envelope", + "candidate_sha": "6e65be48e261d14c64653694320c0185b84fc560", + "session_09_baseline_sha": "1c3f9d6d2312045264134f07b4d00e26de2058bf", + "disposition": "incomplete", + "disposition_reason": "Strict matrix fail-closed: stale PdfTool commit, missing image-heavy-500mb and ten-thousand-page fixtures, -1 preflight/cancel/recovery. Linux hosted matrix not run.", + "fixture_status": { + "office-2mb": {"status": "failed", "preflight_high_water_bytes": -1, "cancellation_latency_ms": -1, "recovery_ms": -1}, + "image-heavy-500mb": {"status": "unavailable", "reason": "fixture-not-supplied"}, + "ten-thousand-page": {"status": "unavailable", "reason": "DIV2K corpus absent locally"}, + "pathological-vector": {"status": "failed", "preflight_high_water_bytes": -1, "cancellation_latency_ms": -1, "recovery_ms": -1}, + "transparency-spots": {"status": "failed", "preflight_high_water_bytes": -1, "cancellation_latency_ms": -1, "recovery_ms": -1}, + "multi-gb": {"status": "unavailable", "reason": "optional"} + }, + "matrix_runs": [ + { + "platform": "windows-local", + "path": "docs/evidence/session-11-resource-envelope/matrix-windows-local.json", + "candidate_sha": "6e65be48e261d14c64653694320c0185b84fc560", + "pdf_tool_commit": "2d95b4a6019504d4e1ffbf658a0e078ab38e34df", + "strict_exit_code": 1, + "summary": {"total": 6, "measured": 0, "flagged": 2, "failed": 3, "candidate_sha_verified": true} + } + ], + "hosted_gaps": [ + "Rebuild PdfTool from candidate_sha on Windows and Linux", + "Supply image-heavy-500mb external fixture", + "Build ten-thousand-page PDF from DIV2K with --hash-all manifest", + "Run Linux hosted run_matrix.py --strict --repetitions 3", + "Cancellation probe on pathological-vector" + ], + "verifiers": [ + {"command": "resource_envelope unit tests", "result": "pass"}, + {"command": "validate_resource_envelope_evidence.py", "result": "pass"}, + {"command": "run_matrix.py --strict windows-local", "result": "fail-closed"} + ] +} diff --git a/docs/evidence/session-11-resource-envelope/fixture-manifest.json b/docs/evidence/session-11-resource-envelope/fixture-manifest.json new file mode 100644 index 000000000..a631b1b14 --- /dev/null +++ b/docs/evidence/session-11-resource-envelope/fixture-manifest.json @@ -0,0 +1,29 @@ +{ + "schema_kind": "loop-resource-envelope-fixtures", + "schema_version": 1, + "fixtures": [ + { + "fixture_id": "office-2mb", + "path": "C:\\.dev\\qualification\\session-11\\fixtures\\office-2mb.pdf", + "sha256": "0f0a45ba88d63f6218f81467ea5a8713ca457c8cb7bcd8f619742d266bbe67c9", + "size_bytes": 2169858, + "provenance": "Session 11 synthetic office workload PDF" + }, + { + "fixture_id": "pathological-vector", + "path": "C:\\.dev\\qualification\\session-11\\fixtures\\pathological-vector.pdf", + "sha256": "5517185518d353635366270ff41e3e2a56cf1d6d21187e84aba8fc6a44aa6c0f", + "size_bytes": 6369566, + "provenance": "pathological_workload.py --family pathological-vector", + "page_count": 256 + }, + { + "fixture_id": "transparency-spots", + "path": "C:\\.dev\\qualification\\session-11\\fixtures\\transparency-spots.pdf", + "sha256": "d4316776aaacb9f056da41925cea51fd68bf1b00ae7b53e1326306e1d85810b3", + "size_bytes": 3266105, + "provenance": "pathological_workload.py --family transparency-spots", + "page_count": 256 + } + ] +} diff --git a/docs/evidence/session-11-resource-envelope/fixture-requirements.json b/docs/evidence/session-11-resource-envelope/fixture-requirements.json new file mode 100644 index 000000000..33937674f --- /dev/null +++ b/docs/evidence/session-11-resource-envelope/fixture-requirements.json @@ -0,0 +1,31 @@ +{ + "schema_kind": "loop-resource-envelope-fixture-requirements", + "schema_version": 1, + "external_root": "C:\\.dev\\qualification\\session-11\\fixtures", + "required_fixtures": [ + { + "fixture_id": "office-2mb", + "availability": "local", + "sha256": "0f0a45ba88d63f6218f81467ea5a8713ca457c8cb7bcd8f619742d266bbe67c9" + }, + { + "fixture_id": "image-heavy-500mb", + "availability": "hosted-required" + }, + { + "fixture_id": "ten-thousand-page", + "availability": "hosted-required", + "provenance": "div2k_workload.py from external DIV2K corpus" + }, + { + "fixture_id": "pathological-vector", + "availability": "local", + "sha256": "5517185518d353635366270ff41e3e2a56cf1d6d21187e84aba8fc6a44aa6c0f" + }, + { + "fixture_id": "transparency-spots", + "availability": "local", + "sha256": "d4316776aaacb9f056da41925cea51fd68bf1b00ae7b53e1326306e1d85810b3" + } + ] +} diff --git a/docs/evidence/session-11-resource-envelope/matrix-windows-local.json b/docs/evidence/session-11-resource-envelope/matrix-windows-local.json new file mode 100644 index 000000000..34e24a900 --- /dev/null +++ b/docs/evidence/session-11-resource-envelope/matrix-windows-local.json @@ -0,0 +1,853 @@ +{ + "schema_kind": "loop-resource-envelope-matrix", + "schema_version": 2, + "candidate_sha": "6e65be48e261d14c64653694320c0185b84fc560", + "candidate_identity": { + "candidate_sha": "6e65be48e261d14c64653694320c0185b84fc560", + "source": "git-head", + "environment_sha": "", + "verified": true + }, + "generated_at_utc": "2026-09-06T23:50:51.501345+00:00", + "fixtures": [ + { + "fixture_id": "office-2mb", + "path": "C:\\.dev\\qualification\\session-11\\fixtures\\office-2mb.pdf", + "expected_page_count": null, + "workload": null, + "profile": { + "render_hw_accel": false, + "render_rasterizers": 8 + }, + "command": [ + "C:\\.dev\\build\\loop-native-qml-deploy-msvc\\install\\usr\\bin\\PdfTool.exe", + "benchmark", + "C:\\.dev\\qualification\\session-11\\fixtures\\office-2mb.pdf", + "--render-hw-accel", + "0", + "--render-rasterizers", + "8", + "--console-format", + "json" + ], + "fixture_sha256": "0f0a45ba88d63f6218f81467ea5a8713ca457c8cb7bcd8f619742d266bbe67c9", + "input_bytes": 2169858, + "provenance": "Session 11 synthetic office workload PDF", + "manifest_sha256": "0f0a45ba88d63f6218f81467ea5a8713ca457c8cb7bcd8f619742d266bbe67c9", + "identity": { + "build": "Release", + "commit": "2d95b4a6019504d4e1ffbf658a0e078ab38e34df", + "compiler": "msvc 1944", + "cpu": "x86_64", + "fixture_digest": "0f0a45ba88d63f6218f81467ea5a8713ca457c8cb7bcd8f619742d266bbe67c9", + "gpu": "unspecified", + "operation_version": "resource-envelope-v2", + "os": "Windows 11 Version 25H2 10.0.26200", + "product_version": "0.2.0-alpha", + "profile_version": "", + "qt": "6.11.1", + "renderer": "loop" + }, + "result": { + "cache_high_water_bytes": 103957205, + "cancellation_latency_ms": -1, + "elapsed_ms": 355, + "family": "benchmark-render", + "identity": { + "build": "Release", + "commit": "2d95b4a6019504d4e1ffbf658a0e078ab38e34df", + "compiler": "msvc 1944", + "cpu": "x86_64", + "fixture_digest": "0f0a45ba88d63f6218f81467ea5a8713ca457c8cb7bcd8f619742d266bbe67c9", + "gpu": "unspecified", + "operation_version": "resource-envelope-v2", + "os": "Windows 11 Version 25H2 10.0.26200", + "product_version": "0.2.0-alpha", + "profile_version": "", + "qt": "6.11.1", + "renderer": "loop" + }, + "incomplete_reason": "resource-budget-exceeded", + "interaction_slot_held": false, + "open_to_first_view_ms": -1, + "page_count": 677, + "pages_materialized": 23, + "prefetch_shed": false, + "preflight_high_water_bytes": -1, + "pressure_shed_count": 654, + "process_commit_high_water_bytes": 160018432, + "recovery_ms": -1, + "resources": { + "config": { + "pool_limits_bytes": { + "active-document-model": 268435456, + "compiled-evidence-cache": 134217728, + "decoded-stream-image-cache": 268435456, + "gpu-texture-cache": 134217728, + "raster-tile-cache": 134217728, + "rollback-storage": 2147483648, + "undo-history": 268435456 + }, + "resident_limit_bytes": 805306368 + }, + "pools": { + "active-document-model": { + "current_bytes": 2973929, + "evictions": 0, + "high_water_bytes": 2973929, + "limit_bytes": 268435456, + "shed": 0 + }, + "compiled-evidence-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 3276, + "limit_bytes": 134217728, + "shed": 0 + }, + "decoded-stream-image-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 268435456, + "shed": 0 + }, + "gpu-texture-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 134217728, + "shed": 0 + }, + "raster-tile-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 100980000, + "limit_bytes": 134217728, + "shed": 654 + }, + "rollback-storage": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 2147483648, + "shed": 0 + }, + "undo-history": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 268435456, + "shed": 0 + } + }, + "pressure": "normal", + "resident_bytes": 2973929, + "resident_high_water_bytes": 103957205 + }, + "rss_high_water_bytes": 148635648, + "status": "budget-exceeded" + }, + "statistics": { + "repetitions": 1, + "elapsed_ms": { + "median": 355, + "min": 355, + "max": 355 + }, + "rss_high_water_bytes": { + "median": 148635648, + "min": 148635648, + "max": 148635648 + }, + "unstable": false + }, + "runs": [ + { + "run": 1, + "status": "recorded", + "process_exit_code": 5, + "result": { + "cache_high_water_bytes": 103957205, + "cancellation_latency_ms": -1, + "elapsed_ms": 355, + "family": "benchmark-render", + "identity": { + "build": "Release", + "commit": "2d95b4a6019504d4e1ffbf658a0e078ab38e34df", + "compiler": "msvc 1944", + "cpu": "x86_64", + "fixture_digest": "0f0a45ba88d63f6218f81467ea5a8713ca457c8cb7bcd8f619742d266bbe67c9", + "gpu": "unspecified", + "operation_version": "resource-envelope-v2", + "os": "Windows 11 Version 25H2 10.0.26200", + "product_version": "0.2.0-alpha", + "profile_version": "", + "qt": "6.11.1", + "renderer": "loop" + }, + "incomplete_reason": "resource-budget-exceeded", + "interaction_slot_held": false, + "open_to_first_view_ms": -1, + "page_count": 677, + "pages_materialized": 23, + "prefetch_shed": false, + "preflight_high_water_bytes": -1, + "pressure_shed_count": 654, + "process_commit_high_water_bytes": 160018432, + "recovery_ms": -1, + "resources": { + "config": { + "pool_limits_bytes": { + "active-document-model": 268435456, + "compiled-evidence-cache": 134217728, + "decoded-stream-image-cache": 268435456, + "gpu-texture-cache": 134217728, + "raster-tile-cache": 134217728, + "rollback-storage": 2147483648, + "undo-history": 268435456 + }, + "resident_limit_bytes": 805306368 + }, + "pools": { + "active-document-model": { + "current_bytes": 2973929, + "evictions": 0, + "high_water_bytes": 2973929, + "limit_bytes": 268435456, + "shed": 0 + }, + "compiled-evidence-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 3276, + "limit_bytes": 134217728, + "shed": 0 + }, + "decoded-stream-image-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 268435456, + "shed": 0 + }, + "gpu-texture-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 134217728, + "shed": 0 + }, + "raster-tile-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 100980000, + "limit_bytes": 134217728, + "shed": 654 + }, + "rollback-storage": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 2147483648, + "shed": 0 + }, + "undo-history": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 268435456, + "shed": 0 + } + }, + "pressure": "normal", + "resident_bytes": 2973929, + "resident_high_water_bytes": 103957205 + }, + "rss_high_water_bytes": 148635648, + "status": "budget-exceeded" + } + } + ], + "validation_errors": [ + "PdfTool identity commit does not match checkout HEAD", + "run 1: identity.commit '2d95b4a6019504d4e1ffbf658a0e078ab38e34df' does not match candidate '6e65be48e261d14c64653694320c0185b84fc560'" + ], + "regressions": [], + "status": "failed", + "required": true + }, + { + "fixture_id": "image-heavy-500mb", + "status": "unavailable", + "reason": "fixture-not-supplied", + "result": null, + "runs": [], + "validation_errors": [], + "regressions": [], + "required": true + }, + { + "fixture_id": "multi-gb", + "status": "unavailable", + "reason": "fixture-not-supplied-optional", + "result": null, + "runs": [], + "validation_errors": [], + "regressions": [], + "required": false + }, + { + "fixture_id": "ten-thousand-page", + "status": "unavailable", + "reason": "fixture-not-supplied", + "result": null, + "runs": [], + "validation_errors": [], + "regressions": [], + "required": true + }, + { + "fixture_id": "pathological-vector", + "path": "C:\\.dev\\qualification\\session-11\\fixtures\\pathological-vector.pdf", + "expected_page_count": 256, + "workload": "pathological-vector", + "profile": { + "render_hw_accel": false, + "render_rasterizers": 8 + }, + "command": [ + "C:\\.dev\\build\\loop-native-qml-deploy-msvc\\install\\usr\\bin\\PdfTool.exe", + "benchmark", + "C:\\.dev\\qualification\\session-11\\fixtures\\pathological-vector.pdf", + "--render-hw-accel", + "0", + "--render-rasterizers", + "8", + "--console-format", + "json" + ], + "fixture_sha256": "5517185518d353635366270ff41e3e2a56cf1d6d21187e84aba8fc6a44aa6c0f", + "input_bytes": 6369566, + "provenance": "pathological_workload.py --family pathological-vector", + "manifest_sha256": "5517185518d353635366270ff41e3e2a56cf1d6d21187e84aba8fc6a44aa6c0f", + "identity": { + "build": "Release", + "commit": "2d95b4a6019504d4e1ffbf658a0e078ab38e34df", + "compiler": "msvc 1944", + "cpu": "x86_64", + "fixture_digest": "5517185518d353635366270ff41e3e2a56cf1d6d21187e84aba8fc6a44aa6c0f", + "gpu": "unspecified", + "operation_version": "resource-envelope-v2", + "os": "Windows 11 Version 25H2 10.0.26200", + "product_version": "0.2.0-alpha", + "profile_version": "", + "qt": "6.11.1", + "renderer": "loop" + }, + "result": { + "cache_high_water_bytes": 108754618, + "cancellation_latency_ms": -1, + "elapsed_ms": 303, + "family": "benchmark-render", + "identity": { + "build": "Release", + "commit": "2d95b4a6019504d4e1ffbf658a0e078ab38e34df", + "compiler": "msvc 1944", + "cpu": "x86_64", + "fixture_digest": "5517185518d353635366270ff41e3e2a56cf1d6d21187e84aba8fc6a44aa6c0f", + "gpu": "unspecified", + "operation_version": "resource-envelope-v2", + "os": "Windows 11 Version 25H2 10.0.26200", + "product_version": "0.2.0-alpha", + "profile_version": "", + "qt": "6.11.1", + "renderer": "loop" + }, + "incomplete_reason": "resource-budget-exceeded", + "interaction_slot_held": false, + "open_to_first_view_ms": -1, + "page_count": 256, + "pages_materialized": 9, + "prefetch_shed": false, + "preflight_high_water_bytes": -1, + "pressure_shed_count": 247, + "process_commit_high_water_bytes": 131481600, + "recovery_ms": -1, + "resources": { + "config": { + "pool_limits_bytes": { + "active-document-model": 268435456, + "compiled-evidence-cache": 134217728, + "decoded-stream-image-cache": 268435456, + "gpu-texture-cache": 134217728, + "raster-tile-cache": 134217728, + "rollback-storage": 2147483648, + "undo-history": 268435456 + }, + "resident_limit_bytes": 805306368 + }, + "pools": { + "active-document-model": { + "current_bytes": 6664198, + "evictions": 0, + "high_water_bytes": 6664198, + "limit_bytes": 268435456, + "shed": 0 + }, + "compiled-evidence-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 1110420, + "limit_bytes": 134217728, + "shed": 0 + }, + "decoded-stream-image-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 268435456, + "shed": 0 + }, + "gpu-texture-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 134217728, + "shed": 0 + }, + "raster-tile-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 100980000, + "limit_bytes": 134217728, + "shed": 247 + }, + "rollback-storage": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 2147483648, + "shed": 0 + }, + "undo-history": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 268435456, + "shed": 0 + } + }, + "pressure": "normal", + "resident_bytes": 6664198, + "resident_high_water_bytes": 108754618 + }, + "rss_high_water_bytes": 147341312, + "status": "budget-exceeded" + }, + "statistics": { + "repetitions": 1, + "elapsed_ms": { + "median": 303, + "min": 303, + "max": 303 + }, + "rss_high_water_bytes": { + "median": 147341312, + "min": 147341312, + "max": 147341312 + }, + "unstable": false + }, + "runs": [ + { + "run": 1, + "status": "recorded", + "process_exit_code": 5, + "result": { + "cache_high_water_bytes": 108754618, + "cancellation_latency_ms": -1, + "elapsed_ms": 303, + "family": "benchmark-render", + "identity": { + "build": "Release", + "commit": "2d95b4a6019504d4e1ffbf658a0e078ab38e34df", + "compiler": "msvc 1944", + "cpu": "x86_64", + "fixture_digest": "5517185518d353635366270ff41e3e2a56cf1d6d21187e84aba8fc6a44aa6c0f", + "gpu": "unspecified", + "operation_version": "resource-envelope-v2", + "os": "Windows 11 Version 25H2 10.0.26200", + "product_version": "0.2.0-alpha", + "profile_version": "", + "qt": "6.11.1", + "renderer": "loop" + }, + "incomplete_reason": "resource-budget-exceeded", + "interaction_slot_held": false, + "open_to_first_view_ms": -1, + "page_count": 256, + "pages_materialized": 9, + "prefetch_shed": false, + "preflight_high_water_bytes": -1, + "pressure_shed_count": 247, + "process_commit_high_water_bytes": 131481600, + "recovery_ms": -1, + "resources": { + "config": { + "pool_limits_bytes": { + "active-document-model": 268435456, + "compiled-evidence-cache": 134217728, + "decoded-stream-image-cache": 268435456, + "gpu-texture-cache": 134217728, + "raster-tile-cache": 134217728, + "rollback-storage": 2147483648, + "undo-history": 268435456 + }, + "resident_limit_bytes": 805306368 + }, + "pools": { + "active-document-model": { + "current_bytes": 6664198, + "evictions": 0, + "high_water_bytes": 6664198, + "limit_bytes": 268435456, + "shed": 0 + }, + "compiled-evidence-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 1110420, + "limit_bytes": 134217728, + "shed": 0 + }, + "decoded-stream-image-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 268435456, + "shed": 0 + }, + "gpu-texture-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 134217728, + "shed": 0 + }, + "raster-tile-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 100980000, + "limit_bytes": 134217728, + "shed": 247 + }, + "rollback-storage": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 2147483648, + "shed": 0 + }, + "undo-history": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 268435456, + "shed": 0 + } + }, + "pressure": "normal", + "resident_bytes": 6664198, + "resident_high_water_bytes": 108754618 + }, + "rss_high_water_bytes": 147341312, + "status": "budget-exceeded" + } + } + ], + "validation_errors": [ + "PdfTool identity commit does not match checkout HEAD", + "run 1: identity.commit '2d95b4a6019504d4e1ffbf658a0e078ab38e34df' does not match candidate '6e65be48e261d14c64653694320c0185b84fc560'" + ], + "regressions": [], + "status": "failed", + "required": true + }, + { + "fixture_id": "transparency-spots", + "path": "C:\\.dev\\qualification\\session-11\\fixtures\\transparency-spots.pdf", + "expected_page_count": 256, + "workload": null, + "profile": { + "render_hw_accel": false, + "render_rasterizers": 8 + }, + "command": [ + "C:\\.dev\\build\\loop-native-qml-deploy-msvc\\install\\usr\\bin\\PdfTool.exe", + "benchmark", + "C:\\.dev\\qualification\\session-11\\fixtures\\transparency-spots.pdf", + "--render-hw-accel", + "0", + "--render-rasterizers", + "8", + "--console-format", + "json" + ], + "fixture_sha256": "d4316776aaacb9f056da41925cea51fd68bf1b00ae7b53e1326306e1d85810b3", + "input_bytes": 3266105, + "provenance": "pathological_workload.py --family transparency-spots", + "manifest_sha256": "d4316776aaacb9f056da41925cea51fd68bf1b00ae7b53e1326306e1d85810b3", + "identity": { + "build": "Release", + "commit": "2d95b4a6019504d4e1ffbf658a0e078ab38e34df", + "compiler": "msvc 1944", + "cpu": "x86_64", + "fixture_digest": "d4316776aaacb9f056da41925cea51fd68bf1b00ae7b53e1326306e1d85810b3", + "gpu": "unspecified", + "operation_version": "resource-envelope-v2", + "os": "Windows 11 Version 25H2 10.0.26200", + "product_version": "0.2.0-alpha", + "profile_version": "", + "qt": "6.11.1", + "renderer": "loop" + }, + "result": { + "cache_high_water_bytes": 105247528, + "cancellation_latency_ms": -1, + "elapsed_ms": 226, + "family": "benchmark-render", + "identity": { + "build": "Release", + "commit": "2d95b4a6019504d4e1ffbf658a0e078ab38e34df", + "compiler": "msvc 1944", + "cpu": "x86_64", + "fixture_digest": "d4316776aaacb9f056da41925cea51fd68bf1b00ae7b53e1326306e1d85810b3", + "gpu": "unspecified", + "operation_version": "resource-envelope-v2", + "os": "Windows 11 Version 25H2 10.0.26200", + "product_version": "0.2.0-alpha", + "profile_version": "", + "qt": "6.11.1", + "renderer": "loop" + }, + "incomplete_reason": "resource-budget-exceeded", + "interaction_slot_held": false, + "open_to_first_view_ms": -1, + "page_count": 256, + "pages_materialized": 3, + "prefetch_shed": false, + "preflight_high_water_bytes": -1, + "pressure_shed_count": 253, + "process_commit_high_water_bytes": 124989440, + "recovery_ms": -1, + "resources": { + "config": { + "pool_limits_bytes": { + "active-document-model": 268435456, + "compiled-evidence-cache": 134217728, + "decoded-stream-image-cache": 268435456, + "gpu-texture-cache": 134217728, + "raster-tile-cache": 134217728, + "rollback-storage": 2147483648, + "undo-history": 268435456 + }, + "resident_limit_bytes": 805306368 + }, + "pools": { + "active-document-model": { + "current_bytes": 3707748, + "evictions": 0, + "high_water_bytes": 3707748, + "limit_bytes": 268435456, + "shed": 0 + }, + "compiled-evidence-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 559780, + "limit_bytes": 134217728, + "shed": 0 + }, + "decoded-stream-image-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 268435456, + "shed": 0 + }, + "gpu-texture-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 134217728, + "shed": 0 + }, + "raster-tile-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 100980000, + "limit_bytes": 134217728, + "shed": 253 + }, + "rollback-storage": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 2147483648, + "shed": 0 + }, + "undo-history": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 268435456, + "shed": 0 + } + }, + "pressure": "normal", + "resident_bytes": 3707748, + "resident_high_water_bytes": 105247528 + }, + "rss_high_water_bytes": 140754944, + "status": "budget-exceeded" + }, + "statistics": { + "repetitions": 1, + "elapsed_ms": { + "median": 226, + "min": 226, + "max": 226 + }, + "rss_high_water_bytes": { + "median": 140754944, + "min": 140754944, + "max": 140754944 + }, + "unstable": false + }, + "runs": [ + { + "run": 1, + "status": "recorded", + "process_exit_code": 5, + "result": { + "cache_high_water_bytes": 105247528, + "cancellation_latency_ms": -1, + "elapsed_ms": 226, + "family": "benchmark-render", + "identity": { + "build": "Release", + "commit": "2d95b4a6019504d4e1ffbf658a0e078ab38e34df", + "compiler": "msvc 1944", + "cpu": "x86_64", + "fixture_digest": "d4316776aaacb9f056da41925cea51fd68bf1b00ae7b53e1326306e1d85810b3", + "gpu": "unspecified", + "operation_version": "resource-envelope-v2", + "os": "Windows 11 Version 25H2 10.0.26200", + "product_version": "0.2.0-alpha", + "profile_version": "", + "qt": "6.11.1", + "renderer": "loop" + }, + "incomplete_reason": "resource-budget-exceeded", + "interaction_slot_held": false, + "open_to_first_view_ms": -1, + "page_count": 256, + "pages_materialized": 3, + "prefetch_shed": false, + "preflight_high_water_bytes": -1, + "pressure_shed_count": 253, + "process_commit_high_water_bytes": 124989440, + "recovery_ms": -1, + "resources": { + "config": { + "pool_limits_bytes": { + "active-document-model": 268435456, + "compiled-evidence-cache": 134217728, + "decoded-stream-image-cache": 268435456, + "gpu-texture-cache": 134217728, + "raster-tile-cache": 134217728, + "rollback-storage": 2147483648, + "undo-history": 268435456 + }, + "resident_limit_bytes": 805306368 + }, + "pools": { + "active-document-model": { + "current_bytes": 3707748, + "evictions": 0, + "high_water_bytes": 3707748, + "limit_bytes": 268435456, + "shed": 0 + }, + "compiled-evidence-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 559780, + "limit_bytes": 134217728, + "shed": 0 + }, + "decoded-stream-image-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 268435456, + "shed": 0 + }, + "gpu-texture-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 134217728, + "shed": 0 + }, + "raster-tile-cache": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 100980000, + "limit_bytes": 134217728, + "shed": 253 + }, + "rollback-storage": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 2147483648, + "shed": 0 + }, + "undo-history": { + "current_bytes": 0, + "evictions": 0, + "high_water_bytes": 0, + "limit_bytes": 268435456, + "shed": 0 + } + }, + "pressure": "normal", + "resident_bytes": 3707748, + "resident_high_water_bytes": 105247528 + }, + "rss_high_water_bytes": 140754944, + "status": "budget-exceeded" + } + } + ], + "validation_errors": [ + "PdfTool identity commit does not match checkout HEAD", + "run 1: identity.commit '2d95b4a6019504d4e1ffbf658a0e078ab38e34df' does not match candidate '6e65be48e261d14c64653694320c0185b84fc560'" + ], + "regressions": [], + "status": "failed", + "required": true + } + ], + "summary": { + "total": 6, + "measured": 0, + "flagged": 2, + "skipped": 1, + "failed": 3, + "candidate_sha_verified": true + } +} diff --git a/docs/evidence/session-12-lifecycle/evidence.json b/docs/evidence/session-12-lifecycle/evidence.json new file mode 100644 index 000000000..f2ab3377f --- /dev/null +++ b/docs/evidence/session-12-lifecycle/evidence.json @@ -0,0 +1,39 @@ +{ + "schema_version": 1, + "evidence_kind": "session-12-lifecycle", + "session": 12, + "branch": "cursor/session-12-lifecycle-qualification", + "baseline_sha": "0c8947c114f5ab4abb256781aefa60b12a0d2da4", + "candidate_sha": "PENDING_COMMIT", + "max_commands": 64, + "corpus_seeds": [ + "seed-20260821.json", + "seed-20260901.json", + "seed-20260906.json", + "seed-20261201.json" + ], + "promoted_failures": [ + "failure-stale-result-minimized.json", + "failure-source-overwritten-minimized.json", + "failure-rollback-history-minimized.json" + ], + "platforms": { + "windows": { + "status": "corpus-static-verified", + "notes": "check_lifecycle_corpus.py pass; UnitTestsLifecycle build requires vcpkg sentry" + }, + "linux": { + "status": "pending-hosted-ci" + } + }, + "verifiers": [ + { + "command": "python scripts/ci/check_lifecycle_corpus.py", + "result": "pass" + } + ], + "gate": { + "id": "L-01", + "disposition": "acceptance verified pending hosted Linux replay on candidate SHA" + } +} diff --git a/docs/evidence/session-13-package-licensing/README.md b/docs/evidence/session-13-package-licensing/README.md new file mode 100644 index 000000000..23dba5d4b --- /dev/null +++ b/docs/evidence/session-13-package-licensing/README.md @@ -0,0 +1,30 @@ +# Session 13 package-licensing evidence + +Frozen evidence for Issues 40–42 (P-02 + P-01 SHA re-proof). + +**Status:** `incomplete` until hosted package workflows run on the exact candidate +SHA and all required artifacts are copied here. + +## Required artifacts (per platform) + +| Artifact | Linux | Windows | +| --- | --- | --- | +| Package-boundary evidence | `linux-evidence.json` | `windows-evidence.json` | +| SPDX SBOM | `linux-components.spdx.json` | `windows-components.spdx.json` | +| Third-party notices | `linux-THIRD_PARTY_NOTICES.txt` | `windows-THIRD_PARTY_NOTICES.txt` | +| Qt relink transcript | `linux-qt-relink.txt` | `windows-qt-relink.txt` | +| Clean-machine smoke | `linux-clean-machine-smoke.txt` | workflow transcript (hosted MSI smoke) | + +## Paired proof + +- `paired-evidence.json` — output of `compare_package_boundary_evidence.py` +- `evidence.json` — Session 13 manifest from `collect_package_licensing_evidence.py` + +## Procedure + +See `docs/SESSION_13_PACKAGE_LICENSING.md`. + +## Session 07 note + +Evidence under `docs/evidence/session-07-package-boundary/` remains historical +qualification on `b47c62b2…` and does **not** satisfy Session 13. diff --git a/docs/evidence/session-13-package-licensing/evidence.json b/docs/evidence/session-13-package-licensing/evidence.json new file mode 100644 index 000000000..9433b2e29 --- /dev/null +++ b/docs/evidence/session-13-package-licensing/evidence.json @@ -0,0 +1,37 @@ +{ + "schema_version": 1, + "kind": "loop-package-licensing-evidence", + "generated_at": "2026-09-06T00:00:00Z", + "source_sha": "pending-hosted-package-build", + "status": "incomplete", + "session": 13, + "policy": "docs/PACKAGING_LICENSING.md", + "procedure": "docs/SESSION_13_PACKAGE_LICENSING.md", + "artifacts": { + "linux": { + "boundary_evidence": null, + "sbom": null, + "third_party_notices": null, + "qt_relink_transcript": null, + "clean_machine_smoke": null + }, + "windows": { + "boundary_evidence": null, + "sbom": null, + "third_party_notices": null, + "qt_relink_transcript": null + }, + "paired_boundary": null + }, + "release_gates": { + "final_artifact_sbom": "open", + "third_party_notices": "partial", + "clean_machine_package_smoke": "open", + "qt_relink_test": "open" + }, + "known_limitations": [ + "Windows Server 2022 pristine VM clean-machine proof remains deferred to 1.0 per Session 07.", + "Session 07 package evidence on b47c62b2 does not transfer; all artifacts must bind to the Session 14 candidate SHA.", + "Hosted Linux_AppImage and Windows_MSI workflow_dispatch runs are required to populate this directory." + ] +} diff --git a/docs/github-milestones/0.10.0.md b/docs/github-milestones/0.10.0.md index 8659beb6d..7f36b401f 100644 --- a/docs/github-milestones/0.10.0.md +++ b/docs/github-milestones/0.10.0.md @@ -1,21 +1,3 @@ -**Planned milestone.** Proposed by `docs/ROADMAP_0.5.0-0.10.0.md`; activates only after 0.9.0 release acceptance. Exits with the 1.0 release-candidate criteria demonstrated on an exact SHA. - -## Canonical name -Platform Hardening, Extension Ecosystem & 1.0 Readiness - -## Critical path -Public-contract freeze → plugin SDK + admission policy → performance/scale program → distribution hardening (signing, updates, macOS decision) → security/privacy audit → 1.0 readiness dossier - -## Objective -Declare and fixture-freeze the 1.0 public contract surface (CLI envelopes, persisted schema kinds, plugin capability ABI, documented behavior) with a deprecation policy; ship the versioned plugin SDK with threat review; enforce performance budgets on named benchmark identities including job-store scale; execute — not inherit — the deferred distribution decisions (installer signing, update channel, macOS go/no-go); refresh the security/privacy audit; and assemble the 1.0 readiness dossier. - -## Owns -The 1.0 contract-surface declaration and deprecation policy, schema compatibility matrix closure, the plugin SDK and admission policy, the benchmark/scale regression program, distribution and platform decisions, the security/privacy audit refresh, operator documentation and onboarding, and the 1.0 readiness dossier. - -## Out of scope -New capability families, authority-boundary widening, a web shell, a second language runtime. A contract that cannot be fixture-frozen is redesigned or labeled experimental — never silently promised. - -## Sequence -0.9.0 → **0.10.0** (planned) → 1.0.0-rc train +**Retired milestone title (2026-09-06 consolidation).** Renumbered to **0.8.0 — Platform Hardening, Extension Ecosystem & 1.0 Readiness**. See `0.8.0.md` and `docs/ROADMAP_0.5.0-0.8.0.md`. GitHub milestone #16 is closed with a retirement note; its session issues (#467–#479) were retitled to 0.8.0-S01–S12. Canonical roadmap: https://app.notion.com/p/38f9cb079ddb804a96dbe26b8d86e84f diff --git a/docs/github-milestones/0.5.0.md b/docs/github-milestones/0.5.0.md index 39f209821..4157fd9d5 100644 --- a/docs/github-milestones/0.5.0.md +++ b/docs/github-milestones/0.5.0.md @@ -1,4 +1,4 @@ -**Planned milestone.** Proposed by `docs/ROADMAP_0.5.0-0.10.0.md`; activates only after 0.4.0 release acceptance and a canonical-roadmap amendment. +**Planned milestone.** Amended by the 2026-09-06 consolidation of `docs/ROADMAP_0.5.0-0.8.0.md`; activates only after 0.4.0 release acceptance. Absorbs the former 0.4.0-B plan-compiler track (#34, #128) and the 0.2.1 job scheduler (#238). ## Canonical name Job Spine & Job-Aware Production Context @@ -10,12 +10,12 @@ Job identity + JobSpec contract → encrypted job spine → artifact/output bind Make Loop the local system of record for production work: a thin, encrypted job spine links request → job → artwork → output → status, and job context flows deterministically into profile resolution and batch execution. Every job field carries per-field provenance and an explicit unknown state; unknown never resolves to a guessed default. ## Owns -JobSpec and job-record schema kinds, job identity and digest linkage, the encrypted local job store and append-only job event log, job-aware profile resolution, and job bindings across desktop, CLI, and PageMaster. +JobSpec and job-record schema kinds, job identity and digest linkage, the encrypted local job store and append-only job event log, job-aware profile resolution, job bindings across desktop, CLI, and PageMaster, and the job scheduler with priority classes and first-class cancellation. ## Out of scope MIS functions (quoting, invoicing, scheduling, inventory), multi-user sync, live e-mail/API intake, recommendations, and any automation behavior. Jobless operation remains fully supported. ## Sequence -0.4.0 → **0.5.0** (planned) → 0.6.0 → 0.7.0 → 0.8.0 → 0.9.0 → 0.10.0 +0.4.0 → **0.5.0** (planned) → 0.6.0 → 0.7.0 → 0.8.0 Canonical roadmap: https://app.notion.com/p/38f9cb079ddb804a96dbe26b8d86e84f diff --git a/docs/github-milestones/0.6.0.md b/docs/github-milestones/0.6.0.md index 5f422e38e..ecec62ecb 100644 --- a/docs/github-milestones/0.6.0.md +++ b/docs/github-milestones/0.6.0.md @@ -1,21 +1,21 @@ -**Planned milestone.** Proposed by `docs/ROADMAP_0.5.0-0.10.0.md`; activates only after 0.5.0 release acceptance. +**Planned milestone.** Amended by the 2026-09-06 consolidation of `docs/ROADMAP_0.5.0-0.8.0.md`; activates only after 0.5.0 release acceptance. Consolidates the former 0.7.0 title (production outcome reconciliation & confirmed-result ledger). ## Canonical name -Governed Intake — Scanned Specifications, Page-Gated OCR & Request-to-JobSpec +Governed Intake & Confirmed Outcomes — Page-Gated OCR, Request-to-JobSpec & Confirmed-Result Ledger ## Critical path -Live-text-first classification → page-gated OCR evidence → deterministic spec parsing → candidate JobSpec with source spans → operator confirmation into the job spine +Intake arc: live-text-first classification → page-gated OCR evidence → deterministic spec parsing → candidate JobSpec with source spans → operator confirmation. Outcomes arc: production-event import → deterministic matching → operator review → confirmed-result ledger. ## Objective -Scanned or image-only inputs and unstructured requests become structured, provenance-carrying JobSpec candidates. OCR output is evidence with producer, confidence, and source span — it never overrides live text and never changes a deterministic verdict except by producing cited evidence records. The operator confirms; the 0.5.0 spine records. +Intake arc: scanned or image-only inputs and unstructured requests become structured, provenance-carrying JobSpec candidates — always operator-confirmed. OCR output is evidence with producer, confidence, and source span; it never overrides live text and never changes a deterministic verdict except by producing cited evidence records. Outcomes arc: production/RIP/press events import through a stable file-based importer boundary, match deterministically to exact jobs and outputs by digest and identifiers, route ambiguity to an operator review queue, and append confirmed outcomes — including grouped samples, retries, splits, and partials — to the job spine. Observe-mode only: record and display, recommend nothing. ## Owns -The OCR evidence contract and Evidence Graph integration, page-gate classification, `LoopOcrService` sidecar lifecycle and packaging, deterministic specification-field parsers (the offline fallback), candidate-JobSpec assembly, optional agent-assisted extraction through the 0.4.0 inference boundary, and the intake review surfaces. +The OCR evidence contract and Evidence Graph integration, page-gate classification, sidecar lifecycle and packaging, deterministic specification-field parsers (the offline fallback), candidate-JobSpec assembly, optional agent-assisted extraction through the 0.4.0 inference boundary, and the intake review surfaces; the production-event schema kind, file-based importers with fail-closed validation and idempotent re-import, the deterministic matching engine and scoring policy, the review/confirmation queue, run-grouping semantics, and observe-mode job-history views. ## Out of scope -Searchable-PDF write-back, OCR-driven verdict changes, live inbox/API integrations, PageMaster batch OCR, training pipelines, handwriting recognition. +Searchable-PDF write-back, OCR-driven verdict changes, live inbox/API integrations, PageMaster batch OCR, training pipelines, handwriting recognition; recommendations (0.7.0), automation, press-vendor API clients as architecture dependencies, automatic confirmation, cost/scheduling analytics. Only operator-confirmed, digest-matched outcomes may ever influence later learning. ## Sequence -0.5.0 → **0.6.0** (planned) → 0.7.0 → 0.8.0 → 0.9.0 → 0.10.0 +0.5.0 → **0.6.0** (planned) → 0.7.0 → 0.8.0 Canonical roadmap: https://app.notion.com/p/38f9cb079ddb804a96dbe26b8d86e84f diff --git a/docs/github-milestones/0.7.0.md b/docs/github-milestones/0.7.0.md index 805445f50..172fb9600 100644 --- a/docs/github-milestones/0.7.0.md +++ b/docs/github-milestones/0.7.0.md @@ -1,21 +1,21 @@ -**Planned milestone.** Proposed by `docs/ROADMAP_0.5.0-0.10.0.md`; activates only after 0.6.0 release acceptance. Graduated-automation stage 1 — **Observe**. +**Planned milestone.** Amended by the 2026-09-06 consolidation of `docs/ROADMAP_0.5.0-0.8.0.md`; activates only after 0.6.0 release acceptance and a minimum confirmed-outcome corpus. This GitHub milestone was formerly titled 0.8.0 and now also consolidates the former 0.9.0. Graduated-automation stages 2–5 — **Recommend**, **Draft workflow**, **Approve**, and **Run with verification**. ## Canonical name -Production Outcome Reconciliation & Confirmed-Result Ledger +Workflow Memory, Recommendations & Verified Repeat Automation ## Critical path -Stable output identity → file-based importer boundary → event normalization → deterministic matching → operator review → confirmed-outcome ledger +Recommendation arc: reconciled chains → deterministic repeat-job signals → evidence-cited recommendation with visible assumptions → decision provenance → draft workflow assembly for review. Automation arc: operator promotion ceremony → match triggers + material-input diff → pre-authorized execution through the governed gateway → divergence pause → lifecycle governance. ## Objective -Import production/RIP/press events, match them to exact jobs and outputs by digest and identifiers, route ambiguity to an operator review queue, and append confirmed outcomes — including grouped samples, retries, splits, and partials — to the job spine. Observe-mode only: record and display, recommend nothing. +Recommendation arc: recognize repeat jobs from confirmed reconciled chains, recommend the previously approved workflow with visible assumptions and differences, record every accept/edit/reject decision append-only, and compile consistently repeated work into draft Action List workflows for operator review. Nothing executes from a recommendation; accepted recommendations pre-fill the normal 0.3.0 governed path. Automation arc: an operator promotes a draft into an approved, versioned, immutable trusted workflow with explicit matching rules, safe-action tiers, and stop conditions; on a matching job Loop proposes — or, where promotion pre-authorizes it, executes — the workflow through the exact 0.3.0 governed path, staging output, revalidating from declared impact, and producing sign-off records; it pauses to the operator whenever confidence falls, material inputs differ, budgets are exceeded, or revalidation is not PASS. Trusted workflows never modify themselves. ## Owns -The production-event schema kind, file-based importers with fail-closed validation and idempotent re-import, the deterministic matching engine and scoring policy, the review/confirmation queue, run-grouping semantics, and observe-mode job-history views. +The repeat-signal feature schema, artwork fingerprinting, matching/scoring policy with material-mismatch hard stops, recommendation records and decision provenance, draft-workflow assembly (compile-only), and the evaluation harness on a named reconciled corpus; the trusted-workflow contract, promotion/revocation/supersession ceremonies, match-trigger evaluation, pre-authorized execution policy inside the governed gateway, divergence/pause semantics, auto-sign-off binding, and workflow lifecycle governance. ## Out of scope -Recommendations, automation, press-vendor API clients as architecture dependencies, automatic confirmation, cost/scheduling analytics. Only operator-confirmed, digest-matched outcomes may ever influence later learning. +Opaque ML ranking, cross-customer data pooling, self-updating drafts, auto-applied recommendations, pricing/scheduling suggestions; self-modifying workflows, frequency-based auto-promotion, unattended operation without a reachable operator, cross-site/multi-tenant automation, agent-initiated promotion. ## Sequence -0.6.0 → **0.7.0** (planned) → 0.8.0 → 0.9.0 → 0.10.0 +0.6.0 → **0.7.0** (planned) → 0.8.0 → 1.0.0-rc train Canonical roadmap: https://app.notion.com/p/38f9cb079ddb804a96dbe26b8d86e84f diff --git a/docs/github-milestones/0.8.0.md b/docs/github-milestones/0.8.0.md index 7366f568d..feada4c96 100644 --- a/docs/github-milestones/0.8.0.md +++ b/docs/github-milestones/0.8.0.md @@ -1,21 +1,21 @@ -**Planned milestone.** Proposed by `docs/ROADMAP_0.5.0-0.10.0.md`; activates only after 0.7.0 release acceptance and a minimum confirmed-outcome corpus. Graduated-automation stages 2–3 — **Recommend** and **Draft workflow**. +**Planned milestone.** Amended by the 2026-09-06 consolidation of `docs/ROADMAP_0.5.0-0.8.0.md`; activates only after 0.7.0 release acceptance. This GitHub milestone was formerly titled 0.10.0. Exits with the 1.0 release-candidate criteria demonstrated on an exact SHA. ## Canonical name -Workflow Memory & Evidence-Backed Recommendations +Platform Hardening, Extension Ecosystem & 1.0 Readiness ## Critical path -Reconciled chains → deterministic repeat-job signals → evidence-cited recommendation with visible assumptions → decision provenance → draft workflow assembly for review +Public-contract freeze → plugin SDK + admission policy → performance/scale program → distribution hardening (signing, updates, macOS decision, SBOM) → security/privacy audit → operator documentation → 1.0 readiness dossier ## Objective -Recognize repeat jobs from confirmed reconciled chains, recommend the previously approved workflow with visible assumptions and differences, record every accept/edit/reject decision append-only, and compile consistently repeated work into draft Action List workflows for operator review. Nothing executes from a recommendation; accepted recommendations pre-fill the normal 0.3.0 governed path. +Declare and freeze the 1.0 public contract surface (CLI envelopes, persisted schema kinds, plugin capability ABI, documented behavior) with a deprecation policy; ship the versioned plugin SDK with admission policy and threat review; run the performance/scale program on named benchmark identities; harden distribution — installer signing decision, update channel, SmartScreen exit, SBOM and signed provenance attestations; decide macOS explicitly; refresh the security/privacy audit; land the deferred 0.3.0 application-quality polish register; complete operator documentation; and assemble the 1.0 readiness dossier with release-candidate criteria demonstrated on an exact SHA. ## Owns -The repeat-signal feature schema, artwork fingerprinting, matching/scoring policy with material-mismatch hard stops, recommendation records and decision provenance, draft-workflow assembly (compile-only), and the evaluation harness on a named reconciled corpus. +The 1.0 contract-surface declaration and deprecation policy, schema compatibility matrix closure, the plugin SDK and admission policy, the benchmark/scale regression program, distribution and platform decisions including SBOM and signed provenance attestations, the security/privacy audit refresh, operator documentation and onboarding, and the 1.0 readiness dossier. ## Out of scope -Workflow promotion and any automatic execution (0.9.0), opaque ML ranking, cross-customer data pooling, self-updating drafts, pricing/scheduling suggestions. +New capability families, authority-boundary widening, a web shell, a second language runtime. A contract that cannot be fixture-frozen is redesigned or labeled experimental — never silently promised. ## Sequence -0.7.0 → **0.8.0** (planned) → 0.9.0 → 0.10.0 +0.7.0 → **0.8.0** (planned) → 1.0.0-rc train Canonical roadmap: https://app.notion.com/p/38f9cb079ddb804a96dbe26b8d86e84f diff --git a/docs/github-milestones/0.9.0.md b/docs/github-milestones/0.9.0.md index 2836db363..8540a7f31 100644 --- a/docs/github-milestones/0.9.0.md +++ b/docs/github-milestones/0.9.0.md @@ -1,24 +1,3 @@ -**Planned milestone.** Proposed by `docs/ROADMAP_0.5.0-0.10.0.md`; activates only after 0.8.0 release acceptance with recommendation quality proven. Graduated-automation stages 4–5 — **Approve** and **Run with verification**. - -## Canonical name -Workflow Promotion & Verified Repeat Automation - -## Critical path -Trusted-workflow contract → operator promotion ceremony → match triggers + material-input diff → pre-authorized execution through the governed gateway → divergence pause → lifecycle governance - -## Objective -An operator promotes a draft into an approved, versioned, immutable trusted workflow with explicit matching rules, safe-action tiers, and stop conditions. On a matching job, Loop proposes — or, where promotion pre-authorizes it, executes — the workflow through the exact 0.3.0 governed path: staged output, mandatory revalidation, bound sign-off records. Any material-input difference, confidence fall, budget breach, or non-PASS revalidation pauses to the operator. Trusted workflows never modify themselves. - -## Authority model -Repeated behavior is evidence, not authorization. Promotion is an explicit operator ceremony the agent cannot perform or propose into existence. Automation never bypasses staging, revalidation, or sign-off; silence is never continuation. - -## Owns -The trusted-workflow contract, promotion/revocation/supersession ceremonies, match-trigger evaluation, pre-authorized execution policy inside the governed gateway, divergence/pause semantics, auto-sign-off binding, and workflow lifecycle governance. - -## Out of scope -Self-modifying workflows, frequency-based auto-promotion, unattended operation without a reachable operator, cross-site/multi-tenant automation, agent-initiated promotion. - -## Sequence -0.8.0 → **0.9.0** (planned) → 0.10.0 +**Retired milestone title (2026-09-06 consolidation).** Living work was consolidated into **0.7.0 — Workflow Memory, Recommendations & Verified Repeat Automation**. See `0.7.0.md` and `docs/ROADMAP_0.5.0-0.8.0.md`. GitHub milestone #15 is closed with a retirement note; its session issues (#454–#466) were retitled to 0.7.0-S09–S17. Canonical roadmap: https://app.notion.com/p/38f9cb079ddb804a96dbe26b8d86e84f diff --git a/docs/github-milestones/README.md b/docs/github-milestones/README.md index 3e6a1349b..b2369f2e2 100644 --- a/docs/github-milestones/README.md +++ b/docs/github-milestones/README.md @@ -14,21 +14,23 @@ Canonical milestone text for [studio-berry/loop](https://github.com/studio-berry | 0.3.0 | 9 | Living | 0.0.5 (supersedes retired `0.1.3` title) | | 0.4.0 | 10 | Living | 0.0.6 (supersedes retired `0.1.4` title) | | 0.5.0 | 11 | Planned (proposed) | — | -| 0.6.0 | 12 | Planned (proposed) | — | -| 0.7.0 | 13 | Planned (proposed) | — | -| 0.8.0 | 14 | Planned (proposed) | — | -| 0.9.0 | 15 | Planned (proposed) | — | -| 0.10.0 | 16 | Planned (proposed) | — | - -The living release train is **0.1.1 → 0.2.0 → 0.3.0 → 0.4.0**. Retired `0.1.2`–`0.1.4` GitHub milestone titles are closed by the sync script. - -The planned continuation **0.5.0 → 0.6.0 → 0.7.0 → 0.8.0 → 0.9.0 → 0.10.0** is scoped in -[`docs/ROADMAP_0.5.0-0.10.0.md`](../ROADMAP_0.5.0-0.10.0.md) and remains proposed until the -canonical Notion roadmap is amended; each planned milestone activates only on its -predecessor's release acceptance. -GitHub milestones `11`–`16` and per-session issues `#403`–`#479` for the planned train -were created on 2026-08-30 as tracker scaffolding; creation does not change the train's -proposed status or the activation rule. +| 0.6.0 | 12 | Planned (proposed) | 0.7.0 (retired 2026-09-06 consolidation title) | +| 0.7.0 | 13 | Planned (proposed) | 0.8.0 (former title; also consolidates retired `0.9.0`) | +| 0.8.0 | 14 | Planned (proposed) | 0.10.0 (retired 2026-09-06 consolidation title) | + +The living release train is **0.1.1 → 0.2.0 → 0.3.0 → 0.4.0**, continuing into the amended +planned train **0.5.0 → 0.6.0 → 0.7.0 → 0.8.0**. + +## Consolidation amendment (2026-09-06) + +The previously proposed 0.5.0–0.10.0 train was consolidated into four releases per the +canonical Notion Roadmap amendment of the same date: **0.6.0 absorbs the former 0.7.0** +(production outcome reconciliation) and **0.7.0 absorbs the former 0.9.0** (workflow +promotion and verified repeat automation); the former **0.10.0 renumbers to 0.8.0**. +Ceremony sessions (S00 reconcile openers, vertical integrations, qualification lanes) were +folded into their owning sessions; nothing was dropped. Scope decomposition: +[`docs/ROADMAP_0.5.0-0.8.0.md`](../ROADMAP_0.5.0-0.8.0.md). Each planned milestone +activates only on its predecessor's release acceptance. ## Sync @@ -44,7 +46,4 @@ Dry run (default): python scripts/github/sync_milestones.py ``` -The script matches milestones by title, creates missing canonical milestones, updates description plus optional open/closed state from [`manifest.json`](manifest.json), and closes retired titles listed under `retire`. - -Note: the planned `0.5.0`–`0.10.0` milestones were created with concise interim -descriptions; `--apply` replaces them with the full canonical text from this directory. +The script matches milestones by title, creates missing canonical milestones, updates description plus optional open/closed state from [`manifest.json`](manifest.json), and closes retired titles listed under `retire` (currently `0.1.2`, `0.1.3`, `0.1.4`, `0.9.0`, `0.10.0`). diff --git a/docs/github-milestones/manifest.json b/docs/github-milestones/manifest.json index e6bca5b84..278315081 100644 --- a/docs/github-milestones/manifest.json +++ b/docs/github-milestones/manifest.json @@ -67,18 +67,6 @@ "github_number": 14, "description_file": "0.8.0.md", "state": "open" - }, - { - "title": "0.9.0", - "github_number": 15, - "description_file": "0.9.0.md", - "state": "open" - }, - { - "title": "0.10.0", - "github_number": 16, - "description_file": "0.10.0.md", - "state": "open" } ], "retire": [ @@ -93,6 +81,14 @@ { "title": "0.1.4", "description": "Retired milestone title. Living work moved to **0.4.0** per the canonical Notion Loop Roadmap." + }, + { + "title": "0.9.0", + "description": "Retired milestone title. Consolidated into **0.7.0** (workflow memory, recommendations & verified repeat automation) per the 2026-09-06 roadmap amendment; see docs/ROADMAP_0.5.0-0.8.0.md." + }, + { + "title": "0.10.0", + "description": "Retired milestone title. Renumbered to **0.8.0** (platform hardening, extension ecosystem & 1.0 readiness) per the 2026-09-06 roadmap amendment; see docs/ROADMAP_0.5.0-0.8.0.md." } ] } diff --git a/scripts/Invoke-MsiSmokeTest.ps1 b/scripts/Invoke-MsiSmokeTest.ps1 index 73edaba5f..64b266458 100644 --- a/scripts/Invoke-MsiSmokeTest.ps1 +++ b/scripts/Invoke-MsiSmokeTest.ps1 @@ -28,6 +28,10 @@ .PARAMETER SourceSha Optional full source SHA to record in the lifecycle smoke transcript. +.PARAMETER QtRelinkTranscript + When supplied, run the packaged Qt copy/launch check before uninstall and + write its transcript to this path. Cleanup still runs if the check fails. + .PARAMETER LogDir Directory for verbose Windows Installer logs. @@ -40,6 +44,7 @@ param( [string]$InstallDir = "${env:ProgramFiles}\LOOP", [string]$TestPdf = "", [string]$SourceSha = "", + [string]$QtRelinkTranscript = "", [string]$LogDir = "$env:TEMP\loop-msi-smoke", [switch]$SkipEditorLaunch, [switch]$AllowOcrSidecar @@ -112,51 +117,76 @@ if (Test-Path -LiteralPath $InstallDir) { "a stale tree cannot mask a packaging defect. Remove it or snapshot back first.") } -if (-not [string]::IsNullOrWhiteSpace($PreviousMsiPath)) { - Write-Host "=== Installing previous version for upgrade coverage ===" - Invoke-Msi -Arguments "/i `"$PreviousMsiPath`"" -LogName "install-previous" - Invoke-Smoke -Stage "previous version" - - Write-Host "=== Upgrading to version under test ===" - Invoke-Msi -Arguments "/i `"$MsiPath`"" -LogName "upgrade" - Invoke-Smoke -Stage "after upgrade" -} else { - Write-Host "=== Installing version under test ===" - Invoke-Msi -Arguments "/i `"$MsiPath`"" -LogName "install" - Invoke-Smoke -Stage "fresh install" -} - -Write-Host "=== Uninstalling ===" -Invoke-Msi -Arguments "/x `"$MsiPath`"" -LogName "uninstall" - -# The current WiX tree places share\loop below INSTALLFOLDER. Keep the -# historical sibling location in the scan as well so an upgrade from an older -# MSI cannot leave files behind unnoticed. -$shareLeftoverRoots = @( - (Join-Path $InstallDir "share\loop"), - (Join-Path (Split-Path -Parent $InstallDir) "share\loop") -) -foreach ($shareLeftoverRoot in $shareLeftoverRoots | Select-Object -Unique) { - if (Test-Path -LiteralPath $shareLeftoverRoot) { - $shareLeftovers = @(Get-ChildItem -LiteralPath $shareLeftoverRoot -Recurse -File -ErrorAction SilentlyContinue) - if ($shareLeftovers.Count -gt 0) { - throw ("Uninstall left $($shareLeftovers.Count) file(s) behind in $shareLeftoverRoot`:`n " + - (($shareLeftovers | Select-Object -First 20 | ForEach-Object { $_.FullName }) -join "`n ")) - } - Write-Host "INFO: $shareLeftoverRoot remains as an empty directory after uninstall" +# Preserve the qualification failure even when cleanup also reports an error. +$qualificationFailure = $null +$installedMsi = $null +try { + if (-not [string]::IsNullOrWhiteSpace($PreviousMsiPath)) { + Write-Host "=== Installing previous version for upgrade coverage ===" + Invoke-Msi -Arguments "/i `"$PreviousMsiPath`"" -LogName "install-previous" + $installedMsi = $PreviousMsiPath + Invoke-Smoke -Stage "previous version" + + Write-Host "=== Upgrading to version under test ===" + Invoke-Msi -Arguments "/i `"$MsiPath`"" -LogName "upgrade" + $installedMsi = $MsiPath + Invoke-Smoke -Stage "after upgrade" + } else { + Write-Host "=== Installing version under test ===" + Invoke-Msi -Arguments "/i `"$MsiPath`"" -LogName "install" + $installedMsi = $MsiPath + Invoke-Smoke -Stage "fresh install" } -} -if (Test-Path -LiteralPath $InstallDir) { - $leftovers = @(Get-ChildItem -LiteralPath $InstallDir -Recurse -File -ErrorAction SilentlyContinue) - if ($leftovers.Count -gt 0) { - throw ("Uninstall left $($leftovers.Count) file(s) behind in $InstallDir`:`n " + - (($leftovers | Select-Object -First 20 | ForEach-Object { $_.FullName }) -join "`n ")) + if ($QtRelinkTranscript) { + & (Join-Path $PSScriptRoot "ci/run_qt_relink_test.ps1") ` + -InstallDir $InstallDir ` + -SourceSha $SourceSha ` + -OutputPath $QtRelinkTranscript + } +} catch { + $qualificationFailure = $_ +} finally { + if ($null -ne $installedMsi) { + try { + Write-Host "=== Uninstalling ===" + Invoke-Msi -Arguments "/x `"$installedMsi`"" -LogName "uninstall" + + # The current WiX tree places share\loop below INSTALLFOLDER. Keep the + # historical sibling location in the scan as well so an upgrade from an older + # MSI cannot leave files behind unnoticed. + $shareLeftoverRoots = @( + (Join-Path $InstallDir "share\loop"), + (Join-Path (Split-Path -Parent $InstallDir) "share\loop") + ) + foreach ($shareLeftoverRoot in $shareLeftoverRoots | Select-Object -Unique) { + if (Test-Path -LiteralPath $shareLeftoverRoot) { + $shareLeftovers = @(Get-ChildItem -LiteralPath $shareLeftoverRoot -Recurse -File -ErrorAction SilentlyContinue) + if ($shareLeftovers.Count -gt 0) { + throw ("Uninstall left $($shareLeftovers.Count) file(s) behind in $shareLeftoverRoot`:`n " + + (($shareLeftovers | Select-Object -First 20 | ForEach-Object { $_.FullName }) -join "`n ")) + } + Write-Host "INFO: $shareLeftoverRoot remains as an empty directory after uninstall" + } + } + + if (Test-Path -LiteralPath $InstallDir) { + $leftovers = @(Get-ChildItem -LiteralPath $InstallDir -Recurse -File -ErrorAction SilentlyContinue) + if ($leftovers.Count -gt 0) { + throw ("Uninstall left $($leftovers.Count) file(s) behind in $InstallDir`:`n " + + (($leftovers | Select-Object -First 20 | ForEach-Object { $_.FullName }) -join "`n ")) + } + Write-Host "INFO: $InstallDir remains as an empty directory after uninstall" + } else { + Write-Host "OK: install directory fully removed" + } + } catch { + if ($null -eq $qualificationFailure) { throw } + Write-Warning "MSI cleanup also failed: $_" + } } - Write-Host "INFO: $InstallDir remains as an empty directory after uninstall" -} else { - Write-Host "OK: install directory fully removed" } +if ($null -ne $qualificationFailure) { throw $qualificationFailure } Write-Host "" Write-Host "MSI lifecycle smoke test passed. Attach this transcript to MIC-301." diff --git a/scripts/ci/check_lifecycle_corpus.py b/scripts/ci/check_lifecycle_corpus.py new file mode 100644 index 000000000..4908266ba --- /dev/null +++ b/scripts/ci/check_lifecycle_corpus.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Validate the lifecycle qualification corpus against manifest.json and schema.""" + +from __future__ import annotations + +import hashlib +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CORPUS_DIR = ROOT / "UnitTests" / "testdata" / "lifecycle" +MANIFEST_PATH = CORPUS_DIR / "manifest.json" + +ALLOWED_KINDS = frozenset( + { + "open", + "render-preflight", + "cancel", + "replace-revision", + "save-reopen", + "rollback", + "close", + } +) +EXPECTED_INVARIANTS = frozenset( + { + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only", + } +) +REPLAY_PROFILES = frozenset( + { + "inject-stale-acceptance", + "inject-source-overwrite", + "inject-history-mutation", + } +) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + payload = handle.read() + digest.update(payload.replace(b"\r\n", b"\n")) + return digest.hexdigest() + + +def validate_trace(path: Path, *, max_commands: int) -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + if payload.get("schema_kind") != "loop-lifecycle-trace": + violations.append((path.name, "schema_kind must be loop-lifecycle-trace")) + if payload.get("schema_version") != 1: + violations.append((path.name, "schema_version must be 1")) + if not payload.get("initial_artifact_digest"): + violations.append((path.name, "initial_artifact_digest is required")) + if not payload.get("observed_result"): + violations.append((path.name, "observed_result is required")) + if not isinstance(payload.get("shrink_history"), list): + violations.append((path.name, "shrink_history must be an array")) + commands = payload.get("commands") + if not isinstance(commands, list) or not commands or len(commands) > max_commands: + violations.append((path.name, f"commands must contain 1..{max_commands} entries")) + return violations + for index, command in enumerate(commands): + if command.get("index") != index: + violations.append((path.name, f"command index mismatch at {index}")) + if command.get("kind") not in ALLOWED_KINDS: + violations.append((path.name, f"unknown command kind at {index}")) + expected = payload.get("expected_invariants") + if not isinstance(expected, list): + violations.append((path.name, "expected_invariants must be an array")) + else: + for value in expected: + if value not in EXPECTED_INVARIANTS: + violations.append((path.name, f"unexpected invariant {value!r}")) + return violations + + +def validate_manifest() -> list[tuple[str, str]]: + violations: list[tuple[str, str]] = [] + with MANIFEST_PATH.open(encoding="utf-8") as handle: + manifest = json.load(handle) + if manifest.get("schema_kind") != "loop-lifecycle-corpus": + violations.append(("manifest.json", "schema_kind must be loop-lifecycle-corpus")) + max_commands = manifest.get("max_commands") + if max_commands != 64: + violations.append(("manifest.json", "max_commands must be 64")) + + passing = manifest.get("passing_traces") + if not isinstance(passing, list) or not passing: + violations.append(("manifest.json", "passing_traces must be a non-empty array")) + else: + for entry in passing: + trace_file = entry.get("trace_file") + if not trace_file: + violations.append(("manifest.json", "passing trace missing trace_file")) + continue + path = CORPUS_DIR / trace_file + if not path.is_file(): + violations.append((trace_file, "missing corpus file")) + continue + if entry.get("sha256") != sha256_file(path): + violations.append((trace_file, "sha256 mismatch")) + violations.extend(validate_trace(path, max_commands=max_commands)) + + failures = manifest.get("failure_traces") + if not isinstance(failures, list) or not failures: + violations.append(("manifest.json", "failure_traces must be a non-empty array")) + else: + for entry in failures: + trace_file = entry.get("trace_file") + profile = entry.get("replay_profile") + if profile not in REPLAY_PROFILES: + violations.append((trace_file or "manifest.json", f"invalid replay_profile {profile!r}")) + if not entry.get("expected_violation"): + violations.append((trace_file or "manifest.json", "expected_violation is required")) + if not trace_file: + continue + path = CORPUS_DIR / trace_file + if not path.is_file(): + violations.append((trace_file, "missing failure trace")) + continue + if entry.get("sha256") != sha256_file(path): + violations.append((trace_file, "sha256 mismatch")) + violations.extend(validate_trace(path, max_commands=max_commands)) + payload = json.loads(path.read_text(encoding="utf-8")) + if payload.get("observed_result") != entry.get("expected_violation"): + violations.append((trace_file, "observed_result must match expected_violation")) + return violations + + +def main() -> int: + violations = validate_manifest() + if violations: + for subject, reason in violations: + print(f"{subject}: {reason}", file=sys.stderr) + return 1 + print("Lifecycle corpus validation passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/check_unmanaged_async.py b/scripts/ci/check_unmanaged_async.py index 34836819a..6b1001a83 100644 --- a/scripts/ci/check_unmanaged_async.py +++ b/scripts/ci/check_unmanaged_async.py @@ -28,9 +28,7 @@ # These are known product debts, not a permission to add more work to the # files. The count check makes the migration observable in every PR. -KNOWN_QTCONCURRENT_COUNTS = { - "LoopLibCore/sources/pdfdiff.cpp": 1, -} +KNOWN_QTCONCURRENT_COUNTS: dict[str, int] = {} SCHEDULER_INTERNALS = { "LoopLibCore/sources/pdfjobscheduler.cpp", @@ -127,7 +125,7 @@ def main() -> int: print(f" {launch.path}:{launch.line}: {launch.kind}: {launch.text}", file=sys.stderr) return 1 - print("Unmanaged async source audit passed; #238 legacy migration debt remains explicit.") + print("Unmanaged async source audit passed; no legacy product QtConcurrent launches remain.") return 0 diff --git a/scripts/ci/collect_package_licensing_evidence.py b/scripts/ci/collect_package_licensing_evidence.py new file mode 100644 index 000000000..29e6b2191 --- /dev/null +++ b/scripts/ci/collect_package_licensing_evidence.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Collect Session 13 package-licensing evidence from final-artifact inputs.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any, Sequence + +_CI_DIR = Path(__file__).resolve().parent +if str(_CI_DIR) not in sys.path: + sys.path.insert(0, str(_CI_DIR)) + +from compare_package_boundary_evidence import compare +from package_licensing_common import LicensingError, load_boundary_evidence, utc_now + + +FULL_SHA = re.compile(r"^[0-9a-fA-F]{40}$") + + +def collect( + linux_evidence: Path, + windows_evidence: Path, + source_sha: str, + linux_sbom: Path | None, + linux_notices: Path | None, + windows_sbom: Path | None, + windows_notices: Path | None, + linux_relink: Path | None, + windows_relink: Path | None, + linux_clean_machine: Path | None, +) -> dict[str, Any]: + if not FULL_SHA.fullmatch(source_sha): + raise LicensingError("source_sha must be a full 40-character Git SHA") + + linux = load_boundary_evidence(linux_evidence) + windows = load_boundary_evidence(windows_evidence) + pair = compare(linux_evidence, windows_evidence, source_sha) + + artifacts = { + "linux": { + "boundary_evidence": linux_evidence.as_posix(), + "sbom": linux_sbom.as_posix() if linux_sbom else None, + "third_party_notices": linux_notices.as_posix() if linux_notices else None, + "qt_relink_transcript": linux_relink.as_posix() if linux_relink else None, + "clean_machine_smoke": linux_clean_machine.as_posix() if linux_clean_machine else None, + "package": linux["package"], + }, + "windows": { + "boundary_evidence": windows_evidence.as_posix(), + "sbom": windows_sbom.as_posix() if windows_sbom else None, + "third_party_notices": windows_notices.as_posix() if windows_notices else None, + "qt_relink_transcript": windows_relink.as_posix() if windows_relink else None, + "package": windows["package"], + }, + "paired_boundary": pair, + } + + required_paths = [ + linux_sbom, + linux_notices, + windows_sbom, + windows_notices, + linux_relink, + windows_relink, + linux_clean_machine, + ] + complete = all(path is not None and path.is_file() for path in required_paths) + status = "passed" if complete and linux["status"] == "passed" and windows["status"] == "passed" else "incomplete" + + return { + "schema_version": 1, + "kind": "loop-package-licensing-evidence", + "generated_at": utc_now(), + "source_sha": source_sha.lower(), + "status": status, + "session": 13, + "policy": "docs/PACKAGING_LICENSING.md", + "procedure": "docs/SESSION_13_PACKAGE_LICENSING.md", + "artifacts": artifacts, + "release_gates": { + "final_artifact_sbom": "complete" if complete else "open", + "third_party_notices": "complete" if complete else "partial", + "clean_machine_package_smoke": "complete" if linux_clean_machine and linux_clean_machine.is_file() else "open", + "qt_relink_test": "complete" if linux_relink and windows_relink and linux_relink.is_file() and windows_relink.is_file() else "open", + }, + "known_limitations": [ + "Windows Server 2022 pristine VM clean-machine proof remains deferred to 1.0 per Session 07.", + "Session 07 package evidence on b47c62b2 does not transfer; all artifacts must bind to this source_sha.", + ], + } + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--linux-evidence", type=Path, required=True) + parser.add_argument("--windows-evidence", type=Path, required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--linux-sbom", type=Path) + parser.add_argument("--linux-notices", type=Path) + parser.add_argument("--windows-sbom", type=Path) + parser.add_argument("--windows-notices", type=Path) + parser.add_argument("--linux-relink", type=Path) + parser.add_argument("--windows-relink", type=Path) + parser.add_argument("--linux-clean-machine", type=Path) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args(argv) + try: + evidence = collect( + args.linux_evidence.resolve(), + args.windows_evidence.resolve(), + args.source_sha, + args.linux_sbom.resolve() if args.linux_sbom else None, + args.linux_notices.resolve() if args.linux_notices else None, + args.windows_sbom.resolve() if args.windows_sbom else None, + args.windows_notices.resolve() if args.windows_notices else None, + args.linux_relink.resolve() if args.linux_relink else None, + args.windows_relink.resolve() if args.windows_relink else None, + args.linux_clean_machine.resolve() if args.linux_clean_machine else None, + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") + except (LicensingError, OSError, ValueError) as exc: + print(f"Package licensing evidence collection FAILED: {exc}", file=sys.stderr) + return 1 + print( + "Package licensing evidence collected: " + f"source_sha={evidence['source_sha']} status={evidence['status']}" + ) + return 0 if evidence["status"] == "passed" else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/generate_package_sbom.py b/scripts/ci/generate_package_sbom.py new file mode 100644 index 000000000..39048bf41 --- /dev/null +++ b/scripts/ci/generate_package_sbom.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Generate an SPDX 2.3 SBOM from final package-boundary evidence.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Sequence + +from package_licensing_common import ( + LicensingError, + group_components, + iter_shipped_binaries, + load_boundary_evidence, + spdx_ref, + utc_now, +) + + +def build_sbom(evidence: dict[str, Any]) -> dict[str, Any]: + platform = str(evidence.get("platform", "unknown")) + source_sha = str(evidence["source_sha"]).lower() + package = evidence.get("package", {}) + binaries = iter_shipped_binaries(evidence) + groups = group_components(binaries) + + document_name = f"Loop-pdf-{platform}-package-sbom" + namespace = f"https://github.com/studio-berry/loop/spdx/{source_sha}/{platform}" + + packages: list[dict[str, Any]] = [ + { + "name": str(package.get("name", "Loop package")), + "SPDXID": "SPDXRef-Package", + "versionInfo": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": True, + "checksums": [ + { + "algorithm": "SHA256", + "checksumValue": str(package.get("sha256", "")), + } + ], + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": f"pkg:github/studio-berry/loop@{source_sha}", + } + ], + } + ] + relationships: list[dict[str, Any]] = [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Package", + } + ] + + for group_name, group in sorted(groups.items(), key=lambda item: item[0].lower()): + component = group["component"] + ref = spdx_ref(group_name) + artifact_paths = [str(item["path"]) for item in group["artifacts"]] + packages.append( + { + "name": group_name, + "SPDXID": ref, + "versionInfo": "NOASSERTION", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": True, + "licenseConcluded": component.spdx_id, + "licenseDeclared": component.spdx_id, + "copyrightText": "NOASSERTION", + "comment": "Shipped in final package payload: " + ", ".join(artifact_paths[:8]) + + (" ..." if len(artifact_paths) > 8 else ""), + } + ) + relationships.append( + { + "spdxElementId": "SPDXRef-Package", + "relationshipType": "CONTAINS", + "relatedSpdxElement": ref, + } + ) + + return { + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": document_name, + "documentNamespace": namespace, + "creationInfo": { + "created": utc_now(), + "creators": ["Tool: loop-generate-package-sbom"], + "comment": ( + "Generated from loop-package-boundary-evidence for the final packaged " + "artifact, not from the vcpkg tree alone." + ), + }, + "documentDescribes": ["SPDXRef-Package"], + "packages": packages, + "relationships": relationships, + "annotations": [ + { + "annotationDate": utc_now(), + "annotationType": "OTHER", + "annotator": "Tool: loop-generate-package-sbom", + "comment": ( + f"source_sha={source_sha}; platform={platform}; " + f"binary_count={len(binaries)}; component_count={len(groups)}" + ), + } + ], + } + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--evidence", type=Path, required=True, help="package-boundary evidence JSON") + parser.add_argument("--output", type=Path, required=True, help="SPDX JSON output path") + args = parser.parse_args(argv) + try: + evidence = load_boundary_evidence(args.evidence.resolve()) + sbom = build_sbom(evidence) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(sbom, indent=2) + "\n", encoding="utf-8") + except (LicensingError, OSError) as exc: + print(f"Package SBOM generation FAILED: {exc}", file=sys.stderr) + return 1 + print( + "Package SBOM generated: " + f"platform={evidence.get('platform')} " + f"source_sha={evidence.get('source_sha')} " + f"components={len(sbom['packages']) - 1}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/generate_package_third_party_notices.py b/scripts/ci/generate_package_third_party_notices.py new file mode 100644 index 000000000..0774531bc --- /dev/null +++ b/scripts/ci/generate_package_third_party_notices.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Generate THIRD_PARTY_NOTICES.txt from final package-boundary evidence.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Any, Sequence + +from package_licensing_common import ( + LicensingError, + group_components, + iter_shipped_binaries, + load_boundary_evidence, + read_notice_text, + utc_now, +) + + +def build_notices(evidence: dict[str, Any]) -> str: + package = evidence.get("package", {}) + groups = group_components(iter_shipped_binaries(evidence)) + lines = [ + "Loop Third-Party Notices", + f"Generated: {utc_now()}", + f"Source SHA: {evidence['source_sha']}", + f"Package: {package.get('name', 'unknown')} ({package.get('format', 'unknown')})", + f"Package SHA256: {package.get('sha256', 'unknown')}", + "", + "This file is generated from the final packaged artifact payload, not from", + "vcpkg.json or the build tree alone. See docs/PACKAGING_LICENSING.md.", + "", + "=" * 78, + "SUMMARY", + "=" * 78, + "", + ] + + for group_name, group in sorted(groups.items(), key=lambda item: item[0].lower()): + component = group["component"] + artifact_count = len(group["artifacts"]) + lines.append(f"- {group_name} ({component.spdx_id}) — {artifact_count} shipped artifact(s)") + + lines.extend(["", "=" * 78, "LICENSE TEXT", "=" * 78, ""]) + + for group_name, group in sorted(groups.items(), key=lambda item: item[0].lower()): + component = group["component"] + lines.extend( + [ + "=" * 78, + f"{group_name} — {component.spdx_id}", + "Shipped artifacts:", + ] + ) + for artifact in group["artifacts"]: + lines.append(f" - {artifact['path']} (sha256 {artifact.get('sha256', 'unknown')})") + lines.append("") + notice = read_notice_text(component) + if notice: + lines.append(notice.rstrip()) + else: + lines.append( + "License text not bundled in-repo for this component. " + "Obtain the upstream license from the component distributor." + ) + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--evidence", type=Path, required=True, help="package-boundary evidence JSON") + parser.add_argument("--output", type=Path, required=True, help="THIRD_PARTY_NOTICES.txt output path") + args = parser.parse_args(argv) + try: + evidence = load_boundary_evidence(args.evidence.resolve()) + notices = build_notices(evidence) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(notices, encoding="utf-8") + except (LicensingError, OSError) as exc: + print(f"Package notices generation FAILED: {exc}", file=sys.stderr) + return 1 + print( + "Package notices generated: " + f"platform={evidence.get('platform')} " + f"source_sha={evidence.get('source_sha')}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/package_licensing_common.py b/scripts/ci/package_licensing_common.py new file mode 100644 index 000000000..76375fcb0 --- /dev/null +++ b/scripts/ci/package_licensing_common.py @@ -0,0 +1,139 @@ +"""Shared helpers for final-artifact SBOM and third-party notices.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + + +ROOT = Path(__file__).resolve().parents[2] +LICENSE_DIR = ROOT / "3rdparty_licenses" +FULL_SHA = re.compile(r"^[0-9a-fA-F]{40}$") + +QT_PATTERN = re.compile(r"^(?:lib)?qt6", re.IGNORECASE) +LOOP_PATTERN = re.compile(r"^(?:lib)?loop", re.IGNORECASE) +PREFLIGHT_PATTERN = re.compile(r"loop-?preflight", re.IGNORECASE) + + +@dataclass(frozen=True) +class ComponentLicense: + name: str + spdx_id: str + notice_file: str | None = None + summary: str | None = None + + +# Basename patterns for shipped shared libraries and executables. Order matters: +# first match wins. +KNOWN_COMPONENTS: tuple[tuple[re.Pattern[str], ComponentLicense], ...] = ( + (QT_PATTERN, ComponentLicense("Qt 6", "LGPL-3.0-only", "Qt-LGPL-3.0.txt")), + (re.compile(r"^(?:lib)?ssl\d*|libcrypto", re.IGNORECASE), ComponentLicense("OpenSSL", "Apache-2.0", "OpenSSL_license.txt")), + (re.compile(r"^liblcms2", re.IGNORECASE), ComponentLicense("Little CMS", "MIT", "LittleCMS_COPYING.txt")), + (re.compile(r"^libopenjp2", re.IGNORECASE), ComponentLicense("OpenJPEG", "BSD-2-Clause", "OpenJPEG_LICENSE.txt")), + (re.compile(r"^libfreetype", re.IGNORECASE), ComponentLicense("FreeType", "FTL", "freetype_FTL.TXT")), + (re.compile(r"^libjpeg", re.IGNORECASE), ComponentLicense("libjpeg-turbo", "IJG", "libjpeg_README.txt")), + (re.compile(r"^libpng\d*", re.IGNORECASE), ComponentLicense("libpng", "Libpng", None)), + (re.compile(r"^libz\.so|^zlib1\.dll$", re.IGNORECASE), ComponentLicense("zlib", "Zlib", "zlib_README.txt")), + (re.compile(r"^libharfbuzz", re.IGNORECASE), ComponentLicense("HarfBuzz", "MIT-Olden", None)), + (re.compile(r"^libbrotli", re.IGNORECASE), ComponentLicense("Brotli", "MIT", None)), + (re.compile(r"^libdouble-conversion", re.IGNORECASE), ComponentLicense("double-conversion", "BSD-3-Clause", None)), + (re.compile(r"^libpcre2", re.IGNORECASE), ComponentLicense("PCRE2", "BSD-3-Clause", None)), + (re.compile(r"^libicu", re.IGNORECASE), ComponentLicense("ICU", "ICU", None)), + (re.compile(r"^libsentry", re.IGNORECASE), ComponentLicense("sentry-native", "MIT", None)), + (LOOP_PATTERN, ComponentLicense("Loop", "MIT", "LOOP-MIT.txt")), + (PREFLIGHT_PATTERN, ComponentLicense("loop-preflight", "MIT", None)), +) + + +class LicensingError(ValueError): + """Raised when package licensing evidence cannot be generated.""" + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def load_boundary_evidence(path: Path) -> dict[str, Any]: + try: + evidence = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise LicensingError(f"unable to read evidence {path}: {exc}") from exc + if evidence.get("schema_version") != 1 or evidence.get("kind") != "loop-package-boundary-evidence": + raise LicensingError(f"unsupported evidence schema: {path}") + if not FULL_SHA.fullmatch(str(evidence.get("source_sha", ""))): + raise LicensingError(f"evidence source SHA is not full length: {path}") + return evidence + + +def basename(path: str) -> str: + return path.replace("\\", "/").rsplit("/", 1)[-1] + + +def classify_binary(path: str) -> ComponentLicense: + name = basename(path) + for pattern, component in KNOWN_COMPONENTS: + if pattern.search(name): + return component + if name.lower() in {"loopeditor", "loopeditor.exe", "pdftool", "pdftool.exe"}: + return ComponentLicense("Loop", "MIT", "LOOP-MIT.txt") + return ComponentLicense(name, "NOASSERTION", None) + + +def iter_shipped_binaries(evidence: dict[str, Any]) -> list[dict[str, Any]]: + binaries = evidence.get("binaries") + if not isinstance(binaries, list): + raise LicensingError("evidence.binaries must be a list") + shipped: list[dict[str, Any]] = [] + for row in binaries: + if not isinstance(row, dict): + continue + if row.get("format") not in {"ELF", "PE"}: + continue + shipped.append(row) + return shipped + + +def group_components(binaries: Iterable[dict[str, Any]]) -> dict[str, dict[str, Any]]: + groups: dict[str, dict[str, Any]] = {} + for row in binaries: + path = str(row.get("path", "")) + component = classify_binary(path) + key = component.name + entry = groups.setdefault( + key, + { + "component": component, + "artifacts": [], + }, + ) + entry["artifacts"].append( + { + "path": path, + "sha256": row.get("sha256"), + "size": row.get("size"), + } + ) + return groups + + +def read_notice_text(component: ComponentLicense) -> str | None: + if component.notice_file: + path = LICENSE_DIR / component.notice_file + if path.is_file(): + return path.read_text(encoding="utf-8") + if component.name == "Qt 6": + return ( + "Qt 6 runtime libraries are redistributed with this package under the " + "GNU Lesser General Public License, version 3. Recipients may replace " + "and relink these libraries per docs/PACKAGING_LICENSING.md." + ) + return component.summary + + +def spdx_ref(name: str) -> str: + normalized = re.sub(r"[^A-Za-z0-9.-]+", "-", name).strip("-") + return f"SPDXRef-{normalized or 'UNKNOWN'}" diff --git a/scripts/ci/run_qt_relink_test.ps1 b/scripts/ci/run_qt_relink_test.ps1 new file mode 100644 index 000000000..4027ee7ee --- /dev/null +++ b/scripts/ci/run_qt_relink_test.ps1 @@ -0,0 +1,101 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + LGPL relink/replace evidence for a Windows MSI installed tree. + +.DESCRIPTION + Copies a shipped Qt6Core.dll through a byte-identical replacement and verifies + LoopEditor still launches via --quick-smoke. Restores the original library + before exit. + +.PARAMETER InstallDir + Installed LOOP directory (64-bit Program Files\LOOP). + +.PARAMETER SourceSha + Optional exact source SHA recorded in the transcript. + +.PARAMETER OutputPath + Optional transcript path. +#> +param( + [Parameter(Mandatory = $true)] + [string]$InstallDir, + [string]$SourceSha = "", + [string]$OutputPath = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Write-Transcript { + param([string]$Message) + if ($OutputPath) { + Add-Content -LiteralPath $OutputPath -Value $Message -Encoding UTF8 + } + Write-Host $Message +} + +# Accept both a binary directory and the MSI install root above usr/bin. +$binDir = $InstallDir +if (-not (Test-Path -LiteralPath (Join-Path $binDir "LoopEditor.exe")) -and + (Test-Path -LiteralPath (Join-Path $binDir "usr/bin/LoopEditor.exe"))) { + $binDir = Join-Path $binDir "usr/bin" +} +$editor = Join-Path $binDir "LoopEditor.exe" +if (-not (Test-Path -LiteralPath $editor)) { + throw "LoopEditor not found under $InstallDir" +} + +$qtCore = Get-ChildItem -LiteralPath $binDir -Filter "Qt6Core.dll" -Recurse -File | Select-Object -First 1 +if (-not $qtCore) { + throw "Qt6Core.dll not found under $InstallDir" +} + +Write-Transcript "Qt relink test: install_dir=$InstallDir" +if ($SourceSha) { + Write-Transcript "source_sha=$SourceSha" +} +Write-Transcript "target_library=$($qtCore.FullName)" + +$backup = "$($qtCore.FullName).loop-relink-bak" +$replacement = "$($qtCore.FullName).loop-relink-replacement" +# This is a byte-identical copy/launch check, not proof of a rebuilt Qt library. +Write-Transcript "replacement_kind=byte-identical-copy" +$environmentNames = @( + "PATH", "QT_QPA_PLATFORM", "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", + "QML_IMPORT_PATH", "QT_QPA_PLATFORM_PLUGIN_PATH", "QTDIR", "QT_ROOT_DIR", + "Qt6_DIR", "LOOP_QT_ROOT", "CMAKE_PREFIX_PATH", "CMAKE_TOOLCHAIN_FILE", "VCPKG_ROOT" +) +$savedEnvironment = @{} +foreach ($name in $environmentNames) { + $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, "Process") +} +Copy-Item -LiteralPath $qtCore.FullName -Destination $backup -Force +try { + Copy-Item -LiteralPath $backup -Destination $replacement -Force + Copy-Item -LiteralPath $replacement -Destination $qtCore.FullName -Force + $env:PATH = "$([Environment]::GetFolderPath('System'));$([Environment]::GetFolderPath('Windows'))" + $env:QT_QPA_PLATFORM = if ($env:QT_QPA_PLATFORM) { $env:QT_QPA_PLATFORM } else { "offscreen" } + foreach ($name in $environmentNames | Where-Object { $_ -notin @("PATH", "QT_QPA_PLATFORM") }) { + [Environment]::SetEnvironmentVariable($name, $null, "Process") + } + $smokeOutput = & $editor --quick-smoke 2>&1 + $smokeExit = $LASTEXITCODE +} finally { + try { + Copy-Item -LiteralPath $backup -Destination $qtCore.FullName -Force + Remove-Item -LiteralPath $backup, $replacement -Force -ErrorAction SilentlyContinue + } finally { + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], "Process") + } + } +} + +if ($smokeExit -ne 0) { + Write-Transcript "Qt relink test FAILED: LoopEditor --quick-smoke exit $smokeExit" + Write-Transcript ($smokeOutput | Out-String) + throw "Qt relink test failed" +} + +Write-Transcript "Qt relink test PASSED: byte-identical Qt6Core copy still launches" diff --git a/scripts/ci/run_qt_relink_test.sh b/scripts/ci/run_qt_relink_test.sh new file mode 100644 index 000000000..b4955df5d --- /dev/null +++ b/scripts/ci/run_qt_relink_test.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# LGPL relink/replace evidence for a Linux AppImage payload. +# +# Usage: +# scripts/ci/run_qt_relink_test.sh /path/to/Loop-pdf-VERSION-x86_64.AppImage [--output transcript.txt] +# +# Copies a shipped Qt6Core shared library through a byte-identical replacement and +# verifies LoopEditor still launches via --quick-smoke. Restores the original +# library before exit. + +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 [--output transcript.txt]" >&2 + exit 1 +fi + +APPIMAGE_PATH="$(readlink -f "$1")" +OUTPUT="" +if [[ "${2:-}" == "--output" ]]; then + OUTPUT="${3:-}" +fi + +if [[ ! -f "$APPIMAGE_PATH" ]]; then + echo "AppImage not found: $APPIMAGE_PATH" >&2 + exit 1 +fi + +log() { + if [[ -n "$OUTPUT" ]]; then + echo "$1" | tee -a "$OUTPUT" + else + echo "$1" + fi +} + +EXTRACT_ROOT="$(mktemp -d)" +cleanup() { + rm -rf "$EXTRACT_ROOT" +} +trap cleanup EXIT + +chmod +x "$APPIMAGE_PATH" +( + cd "$EXTRACT_ROOT" + "$APPIMAGE_PATH" --appimage-extract >/dev/null +) + +ROOT="${EXTRACT_ROOT}/squashfs-root" +BIN_DIR="${ROOT}/usr/bin" +LIB_DIR="${ROOT}/usr/lib" + +QT_CORE="$(find "$LIB_DIR" -maxdepth 2 -name 'libQt6Core.so*' -type f | head -n 1 || true)" +if [[ -z "$QT_CORE" ]]; then + log "Qt relink test FAILED: libQt6Core not found in payload" + exit 1 +fi + +log "Qt relink test: package=$(basename "$APPIMAGE_PATH")" +if [[ -n "${LOOP_SOURCE_SHA:-}" ]]; then + log "source_sha=$(printf '%s' "$LOOP_SOURCE_SHA" | tr '[:upper:]' '[:lower:]')" +fi +log "target_library=${QT_CORE#$ROOT/}" + +# This checks copying and launch, not that a rebuilt Qt library was loaded. +log "replacement_kind=byte-identical-copy" +BACKUP="${QT_CORE}.loop-relink-bak" +REPLACEMENT="${QT_CORE}.loop-relink-replacement" +cp -a "$QT_CORE" "$BACKUP" +cp -a "$BACKUP" "$REPLACEMENT" +cp -a "$REPLACEMENT" "$QT_CORE" + +export PATH="/usr/bin:/bin" +export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}" +export LD_LIBRARY_PATH="$LIB_DIR:$LIB_DIR/x86_64-linux-gnu" +unset QT_PLUGIN_PATH QML2_IMPORT_PATH QML_IMPORT_PATH QT_QPA_PLATFORM_PLUGIN_PATH +unset QTDIR QT_ROOT_DIR Qt6_DIR LOOP_QT_ROOT +unset CMAKE_PREFIX_PATH CMAKE_TOOLCHAIN_FILE VCPKG_ROOT LD_PRELOAD + +set +e +SMOKE_OUTPUT="$("${BIN_DIR}/LoopEditor" --quick-smoke 2>&1)" +SMOKE_EXIT=$? +set -e + +cp -a "$BACKUP" "$QT_CORE" +rm -f "$BACKUP" "$REPLACEMENT" + +if [[ "$SMOKE_EXIT" -ne 0 ]]; then + log "Qt relink test FAILED: LoopEditor --quick-smoke exit ${SMOKE_EXIT}" + log "$SMOKE_OUTPUT" + exit 1 +fi + +log "Qt relink test PASSED: byte-identical Qt6Core copy still launches" +exit 0 diff --git a/scripts/ci/test_generate_package_licensing.py b/scripts/ci/test_generate_package_licensing.py new file mode 100644 index 000000000..fb6e8b0ef --- /dev/null +++ b/scripts/ci/test_generate_package_licensing.py @@ -0,0 +1,112 @@ +"""Unit fixtures for final-artifact SBOM and notices generation.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +COMMON_PATH = Path(__file__).with_name("package_licensing_common.py") +SBOM_PATH = Path(__file__).with_name("generate_package_sbom.py") +NOTICES_PATH = Path(__file__).with_name("generate_package_third_party_notices.py") +COLLECT_PATH = Path(__file__).with_name("collect_package_licensing_evidence.py") + + +def load_module(path: Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +COMMON = load_module(COMMON_PATH, "package_licensing_common") +SBOM = load_module(SBOM_PATH, "generate_package_sbom") +NOTICES = load_module(NOTICES_PATH, "generate_package_third_party_notices") +COLLECT = load_module(COLLECT_PATH, "collect_package_licensing_evidence") + + +def sample_evidence(platform: str) -> dict: + source_sha = "a" * 40 + return { + "schema_version": 1, + "kind": "loop-package-boundary-evidence", + "source_sha": source_sha, + "platform": platform, + "status": "passed", + "forbidden_findings": [], + "checks": { + "all_payload_files_hashed": True, + "all_binary_files_inspected": True, + "target_architecture_matches": True, + "qt6widgets_absent": True, + "qt6widgets_surface_absent": True, + "unresolved_non_system_dependencies_absent": True, + }, + "package": { + "name": f"{platform}.package", + "format": "AppImage" if platform == "linux" else "MSI", + "sha256": "b" * 64, + "size": 123, + }, + "binaries": [ + { + "path": "usr/bin/LoopEditor" if platform == "linux" else "LoopEditor.exe", + "format": "ELF" if platform == "linux" else "PE", + "sha256": "c" * 64, + "size": 10, + }, + { + "path": "usr/lib/libQt6Core.so.6" if platform == "linux" else "Qt6Core.dll", + "format": "ELF" if platform == "linux" else "PE", + "sha256": "d" * 64, + "size": 20, + }, + { + "path": "usr/lib/libssl.so.3" if platform == "linux" else "libssl-3-x64.dll", + "format": "ELF" if platform == "linux" else "PE", + "sha256": "e" * 64, + "size": 30, + }, + ], + } + + +class PackageLicensingTests(unittest.TestCase): + def test_classify_binary_maps_qt_and_openssl(self): + self.assertEqual(COMMON.classify_binary("usr/lib/libQt6Quick.so.6").name, "Qt 6") + self.assertEqual(COMMON.classify_binary("libssl-3-x64.dll").name, "OpenSSL") + + def test_sbom_contains_component_packages(self): + sbom = SBOM.build_sbom(sample_evidence("linux")) + self.assertEqual(sbom["spdxVersion"], "SPDX-2.3") + names = {package["name"] for package in sbom["packages"]} + self.assertIn("Qt 6", names) + self.assertIn("OpenSSL", names) + self.assertIn("Loop", names) + + def test_notices_include_source_sha_and_component_sections(self): + text = NOTICES.build_notices(sample_evidence("windows")) + self.assertIn("a" * 40, text) + self.assertIn("Qt 6", text) + self.assertIn("OpenSSL", text) + + def test_collect_marks_incomplete_without_all_artifacts(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + linux = root / "linux.json" + windows = root / "windows.json" + linux.write_text(json.dumps(sample_evidence("linux")), encoding="utf-8") + windows.write_text(json.dumps(sample_evidence("windows")), encoding="utf-8") + evidence = COLLECT.collect(linux, windows, "a" * 40, None, None, None, None, None, None, None) + self.assertEqual(evidence["status"], "incomplete") + self.assertEqual(evidence["release_gates"]["final_artifact_sbom"], "open") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_run_qt_relink_linux.py b/scripts/ci/test_run_qt_relink_linux.py new file mode 100644 index 000000000..f06769c34 --- /dev/null +++ b/scripts/ci/test_run_qt_relink_linux.py @@ -0,0 +1,63 @@ +"""Executable regression tests for the Linux AppImage Qt relink proof.""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import textwrap +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +RELINK_SCRIPT = ROOT / "scripts" / "ci" / "run_qt_relink_test.sh" + + +class LinuxQtRelinkTests(unittest.TestCase): + def test_extracted_appimage_payload_is_relinked_and_launched(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + appimage = root / "Loop-pdf-test-x86_64.AppImage" + transcript = root / "qt-relink.txt" + appimage.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env bash + set -euo pipefail + test "${1:-}" = "--appimage-extract" + mkdir -p squashfs-root/usr/bin squashfs-root/usr/lib + printf 'recipient-replaceable-qt\n' > squashfs-root/usr/lib/libQt6Core.so.6 + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + 'test "${1:-}" = "--quick-smoke"' \ + 'qt="$(dirname "$0")/../lib/libQt6Core.so.6"' \ + 'test -f "${qt}.loop-relink-bak"' \ + 'test -f "${qt}.loop-relink-replacement"' \ + 'cmp "${qt}.loop-relink-bak" "$qt"' \ + > squashfs-root/usr/bin/LoopEditor + chmod +x squashfs-root/usr/bin/LoopEditor + """ + ), + encoding="utf-8", + ) + appimage.chmod(0o755) + + source_sha = "a" * 40 + result = subprocess.run( + ["bash", str(RELINK_SCRIPT), str(appimage), "--output", str(transcript)], + check=False, + capture_output=True, + text=True, + env={**os.environ, "LOOP_SOURCE_SHA": source_sha, "QT_QPA_PLATFORM": "offscreen"}, + ) + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + evidence = transcript.read_text(encoding="utf-8") + self.assertIn(f"source_sha={source_sha}", evidence) + self.assertIn("Qt relink test PASSED", evidence) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_run_qt_relink_test.py b/scripts/ci/test_run_qt_relink_test.py new file mode 100644 index 000000000..c0b5e6ca7 --- /dev/null +++ b/scripts/ci/test_run_qt_relink_test.py @@ -0,0 +1,214 @@ +"""Execute package scripts with fake payloads; no Qt build or MSI installation.""" + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +SHA = "ABCDEF0123456789" + "01234567" * 3 +STRIPPED = ( + "QT_PLUGIN_PATH", "QML2_IMPORT_PATH", "QML_IMPORT_PATH", + "QT_QPA_PLATFORM_PLUGIN_PATH", "QTDIR", "QT_ROOT_DIR", "Qt6_DIR", + "LOOP_QT_ROOT", "CMAKE_PREFIX_PATH", "CMAKE_TOOLCHAIN_FILE", "VCPKG_ROOT", +) + + +@unittest.skipUnless(os.name == "posix", "POSIX fake AppImage requires bash") +class AppImageRelinkTests(unittest.TestCase): + def run_fixture(self, *, missing_qt=False, smoke_exit=0, extract_exit=0): + with tempfile.TemporaryDirectory(prefix="loop relink ") as directory: + root = Path(directory) + extraction = root / "extraction.txt" + marker = root / "smoke.txt" + transcript = root / "transcript.txt" + appimage = root / "fake package.AppImage" + editor = """#!/bin/bash +set -eu +[ "$1" = "--quick-smoke" ] +""" + "".join(f'[ -z "${{{name}:-}}" ]\n' for name in STRIPPED) + """ +[ -z "${LD_PRELOAD:-}" ] +[ "$PATH" = "/usr/bin:/bin" ] +[ "$QT_QPA_PLATFORM" = "offscreen" ] +bin_dir="$(cd "$(dirname "$0")" && pwd)" +[ "$LD_LIBRARY_PATH" = "${bin_dir%/bin}/lib:${bin_dir%/bin}/lib/x86_64-linux-gnu" ] +[ "$(cat "$bin_dir/../lib/libQt6Core.so.6")" = "original Qt fixture" ] +[ -f "$bin_dir/../lib/libQt6Core.so.6.loop-relink-bak" ] +printf 'smoke reached' > "$LOOP_TEST_MARKER" +exit "$LOOP_TEST_SMOKE_EXIT" +""" + appimage.write_text("""#!/bin/bash +set -eu +[ "$1" = "--appimage-extract" ] +printf '%s' "$PWD" > "$LOOP_TEST_EXTRACTION" +if [ "$LOOP_TEST_EXTRACT_EXIT" != 0 ]; then exit "$LOOP_TEST_EXTRACT_EXIT"; fi +mkdir -p squashfs-root/usr/bin squashfs-root/usr/lib +if [ "$LOOP_TEST_MISSING_QT" != 1 ]; then + printf 'original Qt fixture' > squashfs-root/usr/lib/libQt6Core.so.6 +fi +cat > squashfs-root/usr/bin/LoopEditor <<'EDITOR' +""" + editor + "EDITOR\nchmod +x squashfs-root/usr/bin/LoopEditor\n") + environment = os.environ.copy() + environment.update({name: "/fake/developer/path" for name in STRIPPED}) + environment.pop("LD_PRELOAD", None) + environment.update( + LOOP_SOURCE_SHA=SHA, QT_QPA_PLATFORM="offscreen", + LD_LIBRARY_PATH="/fake/developer/lib", + LOOP_TEST_EXTRACTION=str(extraction), LOOP_TEST_MARKER=str(marker), + LOOP_TEST_SMOKE_EXIT=str(smoke_exit), + LOOP_TEST_EXTRACT_EXIT=str(extract_exit), + LOOP_TEST_MISSING_QT=str(int(missing_qt)), + ) + result = subprocess.run( + ["bash", str(ROOT / "scripts/ci/run_qt_relink_test.sh"), + str(appimage), "--output", str(transcript)], + env=environment, capture_output=True, text=True, timeout=30, + ) + self.assertTrue(extraction.exists(), result.stderr) + self.assertFalse(Path(extraction.read_text()).exists(), "Extraction leaked") + return result, marker.exists(), transcript.read_text() if transcript.exists() else "" + + def test_final_payload_smoke_strips_developer_environment_and_records_sha(self): + result, launched, transcript = self.run_fixture() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue(launched) + self.assertIn(f"source_sha={SHA.lower()}", transcript) + self.assertIn("target_library=usr/lib/libQt6Core.so.6", transcript) + self.assertIn("replacement_kind=byte-identical-copy", transcript) + + def test_missing_packaged_qt_fails_before_launch(self): + result, launched, transcript = self.run_fixture(missing_qt=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(launched) + self.assertIn("libQt6Core not found", transcript) + self.assertNotIn("PASSED", transcript) + + def test_smoke_failure_propagates_and_cleans_extraction(self): + result, launched, transcript = self.run_fixture(smoke_exit=17) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(launched) + self.assertIn("exit 17", transcript) + self.assertNotIn("PASSED", transcript) + + def test_extraction_failure_propagates_and_cleans_extraction(self): + result, launched, transcript = self.run_fixture(extract_exit=19) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(launched) + self.assertNotIn("PASSED", transcript) + + +@unittest.skipUnless(os.name == "nt" and shutil.which("pwsh"), "Windows PowerShell required") +class MsiLifecycleTests(unittest.TestCase): + def run_fixture(self, *, fail_smoke=False, fail_relink=False, fail_uninstall=False, + relink=True, stale=False): + with tempfile.TemporaryDirectory(prefix="loop lifecycle ") as directory: + root = Path(directory) + (root / "ci").mkdir() + shutil.copyfile(ROOT / "scripts/Invoke-MsiSmokeTest.ps1", root / "lifecycle.ps1") + (root / "fake.msi").touch() + (root / "smoke-test-install.ps1").write_text('''param($InstallDir, $SourceSha, [switch]$SkipEditorLaunch) +if (-not $global:installed) { throw "smoke ran after removal" } +$global:events.Add("smoke") +if ($env:LOOP_TEST_FAIL_SMOKE -eq '1') { throw "fixture smoke failed" } +''') + (root / "ci/run_qt_relink_test.ps1").write_text('''param($InstallDir, $SourceSha, $OutputPath) +if (-not $global:installed) { throw "relink ran after removal" } +if ($SourceSha -ne $env:LOOP_TEST_SHA) { throw "source SHA lost" } +$global:events.Add("relink") +Set-Content -LiteralPath $OutputPath -Value "source_sha=$SourceSha" +if ($env:LOOP_TEST_FAIL_RELINK -eq '1') { throw "fixture relink failed" } +''') + (root / "driver.ps1").write_text(r'''$ErrorActionPreference = "Stop" +$global:events = [Collections.Generic.List[string]]::new() +$global:installed = $env:LOOP_TEST_STALE -eq '1' +$global:fixtureInstall = Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "LOOP-FAKE-QUALIFICATION" +function Test-Path { + param($LiteralPath) + if ($LiteralPath -eq $global:fixtureInstall) { return $global:installed } + if ($LiteralPath -like "$global:fixtureInstall*" -or + $LiteralPath -eq (Join-Path ([Environment]::GetFolderPath("ProgramFiles")) "share/loop")) { return $false } + Microsoft.PowerShell.Management\Test-Path -LiteralPath $LiteralPath +} +function Start-Process { + param($FilePath, $ArgumentList, [switch]$Wait, [switch]$PassThru) + if ($FilePath -ne 'msiexec.exe') { throw "unexpected process" } + if ($ArgumentList.StartsWith('/i ')) { + $global:installed = $true + $global:events.Add("install") + } elseif ($ArgumentList.StartsWith('/x ')) { + $global:events.Add("uninstall") + if ($env:LOOP_TEST_FAIL_UNINSTALL -eq '1') { return [pscustomobject]@{ExitCode=1603} } + $global:installed = $false + } else { throw "unexpected MSI arguments" } + return [pscustomobject]@{ExitCode=0} +} +$arguments = @{ + MsiPath = (Join-Path $PSScriptRoot 'fake.msi') + InstallDir = $global:fixtureInstall + SourceSha = $env:LOOP_TEST_SHA + LogDir = (Join-Path $PSScriptRoot 'logs') + SkipEditorLaunch = $true +} +if ($env:LOOP_TEST_RELINK -eq '1') { + $arguments.QtRelinkTranscript = Join-Path $PSScriptRoot 'relink.txt' +} +$failed = $false +try { & (Join-Path $PSScriptRoot 'lifecycle.ps1') @arguments } +catch { Write-Output "FAILURE: $_"; $failed = $true } +Write-Output ("EVENTS=" + ($global:events -join ',')) +if ($failed) { exit 1 } +''') + environment = os.environ.copy() + environment.update(LOOP_TEST_SHA=SHA) + for name, value in dict(FAIL_SMOKE=fail_smoke, FAIL_RELINK=fail_relink, + FAIL_UNINSTALL=fail_uninstall, RELINK=relink, + STALE=stale).items(): + environment[f"LOOP_TEST_{name}"] = str(int(value)) + return subprocess.run( + ["pwsh", "-NoProfile", "-File", str(root / "driver.ps1")], + env=environment, capture_output=True, text=True, timeout=30, + ) + + def test_relink_runs_between_smoke_and_uninstall(self): + result = self.run_fixture() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("EVENTS=install,smoke,relink,uninstall", result.stdout) + + def test_optional_relink_preserves_default_lifecycle(self): + result = self.run_fixture(relink=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("EVENTS=install,smoke,uninstall", result.stdout) + + def test_relink_failure_still_uninstalls(self): + result = self.run_fixture(fail_relink=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("fixture relink failed", result.stdout) + self.assertIn("EVENTS=install,smoke,relink,uninstall", result.stdout) + + def test_smoke_failure_still_uninstalls(self): + result = self.run_fixture(fail_smoke=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("EVENTS=install,smoke,uninstall", result.stdout) + + def test_uninstall_failure_fails_qualification(self): + result = self.run_fixture(fail_uninstall=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("msiexec failed", result.stdout) + + def test_cleanup_failure_preserves_primary_failure(self): + result = self.run_fixture(fail_relink=True, fail_uninstall=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("FAILURE: fixture relink failed", result.stdout) + self.assertIn("cleanup also failed", result.stdout) + + def test_stale_installation_is_rejected_without_mutation(self): + result = self.run_fixture(stale=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("already exists", result.stdout) + self.assertIn("EVENTS=\n", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_workflow_contracts.py b/scripts/ci/test_workflow_contracts.py index caca118ba..b3b569e50 100644 --- a/scripts/ci/test_workflow_contracts.py +++ b/scripts/ci/test_workflow_contracts.py @@ -121,11 +121,54 @@ def test_package_workflows_require_and_record_exact_source_sha(self): self.assertIn("LOOP_SOURCE_SHA", workflow) self.assertIn("inspect_package_dependencies.py", workflow) self.assertIn("source-sha", workflow) + self.assertIn("run-name: Linux_AppImage (${{ inputs.source_sha }})", linux) + self.assertIn("run-name: Windows_MSI (${{ inputs.source_sha }})", windows) self.assertIn("--expected-architecture x86-64", linux) self.assertIn("--expected-architecture x64", windows) self.assertIn("loop-package-boundary-linux-evidence", linux) self.assertIn("loop-package-boundary-windows-evidence", windows) + def test_linux_relink_uses_final_appimage_after_packaging(self): + workflow = (ROOT / ".github/workflows/LinuxInstall.yml").read_text(encoding="utf-8") + steps = workflow.split(" - name: ") + names = [step.splitlines()[0] for step in steps] + relink_index = names.index("Run Qt LGPL relink test") + for prerequisite in ( + "Pack AppImage (unsigned — V1 default)", "Sign and Repack AppImage", + "Run AppImage smoke test", "Inspect AppImage package boundary", + "Generate final-artifact SBOM and notices", + ): + self.assertLess(names.index(prerequisite), relink_index) + self.assertLess(relink_index, names.index("Upload AppImage Package")) + relink = steps[relink_index] + self.assertIn('"build/${{ env.appimagefilename }}"', relink) + self.assertIn('--output "$evidence_dir/qt-relink.txt"', relink) + script = (ROOT / "scripts/ci/run_qt_relink_test.sh").read_text(encoding="utf-8") + self.assertIn("LOOP_SOURCE_SHA", script) + self.assertIn("source_sha=", script) + + def test_windows_relink_is_owned_by_msi_lifecycle(self): + workflow = (ROOT / ".github/workflows/WindowsInstall.yml").read_text(encoding="utf-8") + lifecycle = workflow.split(" - name: Run MSI lifecycle smoke test")[1].split(" - name:")[0] + self.assertIn('-QtRelinkTranscript (Join-Path $evidenceDir "qt-relink.txt")', lifecycle) + self.assertIn("-SourceSha $env:LOOP_SOURCE_SHA", lifecycle) + self.assertNotIn(" - name: Run Qt LGPL relink test", workflow) + self.assertNotIn("-SkipUninstall", workflow) + script = (ROOT / "scripts/Invoke-MsiSmokeTest.ps1").read_text(encoding="utf-8") + self.assertLess(script.index('Invoke-Smoke -Stage "fresh install"'), + script.index('if ($QtRelinkTranscript)')) + self.assertLess(script.index('ci/run_qt_relink_test.ps1'), + script.index('Write-Host "=== Uninstalling ==="')) + self.assertIn("} finally {", script) + + def test_fake_package_tests_run_on_linux_and_windows(self): + workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + job = workflow.split(" package_script_tests:")[1].split(" source_integrity:")[0] + self.assertIn("os: [ubuntu-22.04, windows-latest]", job) + self.assertIn("python -m unittest scripts.ci.test_run_qt_relink_test -v", job) + self.assertIn("python -m unittest scripts.ci.test_run_qt_relink_linux -v", job) + self.assertIn("if: runner.os == 'Linux'", job) + def test_blacksmith_is_reserved_for_windows_msi_only(self): workflows_dir = ROOT / ".github/workflows" blacksmith_workflows = [] @@ -173,10 +216,22 @@ def test_release_draft_pairs_evidence_and_keeps_it_out_of_assets(self): self.assertIn("compare_package_boundary_evidence.py", workflow) self.assertIn("loop-package-boundary-linux-evidence", workflow) self.assertIn("loop-package-boundary-windows-evidence", workflow) - self.assertIn('--commit "$EXPECTED_SOURCE_SHA"', workflow) + self.assertIn('--arg title "Linux_AppImage (${EXPECTED_SOURCE_SHA})"', workflow) + self.assertIn('--arg title "Windows_MSI (${EXPECTED_SOURCE_SHA})"', workflow) + self.assertNotIn('--commit "$EXPECTED_SOURCE_SHA"', workflow) self.assertIn("Exclude CI evidence from release assets", workflow) self.assertIn("source_sha", workflow) + def test_windows_relink_runs_before_msi_uninstall(self): + workflow = (ROOT / ".github/workflows/WindowsInstall.yml").read_text(encoding="utf-8") + smoke = (ROOT / "scripts/Invoke-MsiSmokeTest.ps1").read_text(encoding="utf-8") + self.assertIn('-QtRelinkTranscript (Join-Path $evidenceDir "qt-relink.txt")', workflow) + self.assertNotIn("- name: Run Qt LGPL relink test", workflow) + self.assertLess( + smoke.index("ci/run_qt_relink_test.ps1"), + smoke.index('Write-Host "=== Uninstalling ==="'), + ) + def test_release_wix_template_does_not_unconditionally_ship_widgets(self): product = (ROOT / "WixInstaller/Product.wxs.in").read_text(encoding="utf-8") cmake = (ROOT / "WixInstaller/CMakeLists.txt").read_text(encoding="utf-8") diff --git a/scripts/qualification/build_session10_trust_evidence.py b/scripts/qualification/build_session10_trust_evidence.py new file mode 100644 index 000000000..ea3d7ed07 --- /dev/null +++ b/scripts/qualification/build_session10_trust_evidence.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Build Session 10 independent-validation evidence from the frozen fixture manifest.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import platform +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MANIFEST = ROOT / "docs/evidence/session-10-trust/conversion-fixture-manifest.json" +VALIDATOR = ROOT / "scripts/qualification/run_independent_validators.py" +CLAIMS = ("structural", "signature", "standards") + + +def _sha256(path: Path) -> tuple[int, str]: + digest = hashlib.sha256() + size = 0 + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + size += len(chunk) + digest.update(chunk) + return size, digest.hexdigest() + + +def _git_sha() -> str: + completed = subprocess.run( + ["git", "-C", str(ROOT), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + + +def _load_manifest() -> dict: + with MANIFEST.open(encoding="utf-8") as stream: + return json.load(stream) + + +def _resolve_input(manifest: dict) -> Path: + qualification = manifest["qualification_pdf"] + path = ROOT / qualification["path"] + if not path.is_file(): + raise FileNotFoundError(f"qualification PDF missing: {path}") + size, digest = _sha256(path) + if digest != qualification["sha256"]: + raise ValueError( + f"qualification PDF digest mismatch for {path}: expected {qualification['sha256']}, got {digest} ({size} bytes)" + ) + return path + + +def _run_validator(input_path: Path, output_path: Path, candidate_sha: str) -> dict: + command = [ + sys.executable, + str(VALIDATOR), + "--input", + str(input_path), + "--output", + str(output_path), + "--candidate-sha", + candidate_sha, + *sum([["--claim", claim] for claim in CLAIMS], []), + ] + completed = subprocess.run(command, cwd=ROOT) + if completed.returncode not in (0, 1, 2): + raise subprocess.CalledProcessError(completed.returncode, command) + with output_path.open(encoding="utf-8") as stream: + return json.load(stream) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", + type=Path, + help="Evidence JSON path (default: docs/evidence/session-10-trust/independent-validation-.json)", + ) + parser.add_argument("--candidate-sha", help="40-char candidate SHA (default: git HEAD)") + args = parser.parse_args() + + manifest = _load_manifest() + input_path = _resolve_input(manifest) + candidate_sha = args.candidate_sha or _git_sha() + + system = platform.system().lower() + default_name = "windows" if system == "windows" else "linux" + output_path = args.output or ROOT / f"docs/evidence/session-10-trust/independent-validation-{default_name}.json" + output_path.parent.mkdir(parents=True, exist_ok=True) + + evidence = _run_validator(input_path, output_path, candidate_sha) + print(json.dumps(evidence, indent=2)) + return {"passed": 0, "rejected": 1, "incomplete": 2}[evidence["status"]] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/qualification/generate_lifecycle_corpus.py b/scripts/qualification/generate_lifecycle_corpus.py new file mode 100644 index 000000000..8c65802a3 --- /dev/null +++ b/scripts/qualification/generate_lifecycle_corpus.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Generate deterministic lifecycle trace corpus files for UnitTestsLifecycle.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CORPUS_DIR = ROOT / "UnitTests" / "testdata" / "lifecycle" +MAX_COMMANDS = 64 +SOURCE_DIGEST = hashlib.sha256(b"lifecycle-source-v1").hexdigest() +EXPECTED_INVARIANTS = [ + "source-immutable", + "cancel-is-terminal", + "stale-results-rejected", + "history-append-only", +] +ACTIVE_KINDS = [ + "open", + "render-preflight", + "cancel", + "replace-revision", + "save-reopen", + "rollback", +] +CORPUS_SEEDS = ( + 0x20260821, + 0x20260901, + 0x20260906, + 0x20261201, +) +FAILURE_TRACES = ( + { + "file": "failure-stale-result-minimized.json", + "replay_profile": "inject-stale-acceptance", + "expected_violation": "stale-result-accepted", + "commands": (("open", 9073021129658994722),), + "shrink_history": [64, 1], + }, + { + "file": "failure-source-overwritten-minimized.json", + "replay_profile": "inject-source-overwrite", + "expected_violation": "source-overwritten", + "commands": (("open", 9073021129658994722),), + "shrink_history": [64, 1], + }, + { + "file": "failure-rollback-history-minimized.json", + "replay_profile": "inject-history-mutation", + "expected_violation": "rollback-history-mutated", + "commands": ( + ("open", 9073021129658994722), + ("save-reopen", 5643642477061534660), + ), + "shrink_history": [64, 2], + }, +) + + +def next_trace_random(state: int) -> tuple[int, int]: + state = (state + 0x9E3779B97F4A7C15) & 0xFFFFFFFFFFFFFFFF + value = state + value = ((value ^ (value >> 30)) * 0xBF58476D1CE4E5B9) & 0xFFFFFFFFFFFFFFFF + value = ((value ^ (value >> 27)) * 0x94D049BB133111EB) & 0xFFFFFFFFFFFFFFFF + return value ^ (value >> 31), state + + +def generate_trace(seed: int, max_commands: int = MAX_COMMANDS) -> list[tuple[str, int]]: + trace: list[tuple[str, int]] = [] + state = seed + for kind in ACTIVE_KINDS: + argument, state = next_trace_random(state) + trace.append((kind, argument)) + while len(trace) < max_commands - 1: + kind_index, state = next_trace_random(state) + kind = ACTIVE_KINDS[kind_index % len(ACTIVE_KINDS)] + argument, state = next_trace_random(state) + trace.append((kind, argument)) + argument, state = next_trace_random(state) + trace.append(("close", argument)) + return trace + + +def trace_to_json( + seed: int, + trace: list[tuple[str, int]], + observed_result: str, + shrink_history: list[int], +) -> dict: + commands = [ + {"index": index, "kind": kind, "argument": str(argument)} + for index, (kind, argument) in enumerate(trace) + ] + return { + "schema_kind": "loop-lifecycle-trace", + "schema_version": 1, + "seed": seed, + "initial_artifact_digest": SOURCE_DIGEST, + "commands": commands, + "expected_invariants": EXPECTED_INVARIANTS, + "observed_result": observed_result, + "shrink_history": shrink_history, + } + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + payload = handle.read() + digest.update(payload.replace(b"\r\n", b"\n")) + return digest.hexdigest() + + +def main() -> int: + CORPUS_DIR.mkdir(parents=True, exist_ok=True) + passing_traces = [] + for seed in CORPUS_SEEDS: + trace = generate_trace(seed) + filename = f"seed-{seed:08x}.json" + path = CORPUS_DIR / filename + path.write_text( + json.dumps(trace_to_json(seed, trace, "invariants-held", []), indent=2) + "\n", + encoding="utf-8", + newline="\n", + ) + passing_traces.append( + { + "seed": seed, + "trace_file": filename, + "command_count": len(trace), + "sha256": sha256_file(path), + "observed_result": "invariants-held", + } + ) + + failure_entries = [] + for entry in FAILURE_TRACES: + payload = trace_to_json( + 0x20260821, + list(entry["commands"]), + entry["expected_violation"], + list(entry["shrink_history"]), + ) + path = CORPUS_DIR / entry["file"] + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8", newline="\n") + failure_entries.append( + { + "trace_file": entry["file"], + "replay_profile": entry["replay_profile"], + "expected_violation": entry["expected_violation"], + "command_count": len(entry["commands"]), + "sha256": sha256_file(path), + } + ) + + manifest = { + "schema_kind": "loop-lifecycle-corpus", + "schema_version": 1, + "max_commands": MAX_COMMANDS, + "initial_artifact_digest": SOURCE_DIGEST, + "passing_traces": passing_traces, + "failure_traces": failure_entries, + } + manifest_path = CORPUS_DIR / "manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8", newline="\n") + print(f"Wrote {len(passing_traces)} passing and {len(failure_entries)} failure traces to {CORPUS_DIR}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/qualification/test_validate_resource_envelope_evidence.py b/scripts/qualification/test_validate_resource_envelope_evidence.py new file mode 100644 index 000000000..7f07e7e74 --- /dev/null +++ b/scripts/qualification/test_validate_resource_envelope_evidence.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import unittest + +from scripts.qualification.validate_resource_envelope_evidence import validate_evidence + + +class ValidateResourceEnvelopeEvidenceTest(unittest.TestCase): + def test_incomplete_evidence_is_valid(self) -> None: + evidence = { + "schema_kind": "loop-resource-envelope-qualification-evidence", + "schema_version": 1, + "candidate_sha": "6e65be48e261d14c64653694320c0185b84fc560", + "disposition": "incomplete", + "fixture_status": { + "office-2mb": {"status": "failed", "preflight_high_water_bytes": -1}, + "image-heavy-500mb": {"status": "unavailable"}, + "ten-thousand-page": {"status": "unavailable"}, + "pathological-vector": {"status": "failed", "preflight_high_water_bytes": -1}, + "transparency-spots": {"status": "failed", "preflight_high_water_bytes": -1}, + }, + "matrix_runs": [{"candidate_sha": "6e65be48e261d14c64653694320c0185b84fc560", "summary": {}}], + "verifiers": [{"command": "test", "result": "pass"}], + } + self.assertEqual(validate_evidence(evidence), []) + + def test_passed_disposition_is_rejected(self) -> None: + evidence = { + "schema_kind": "loop-resource-envelope-qualification-evidence", + "schema_version": 1, + "candidate_sha": "6e65be48e261d14c64653694320c0185b84fc560", + "disposition": "passed", + "fixture_status": { + "office-2mb": {"status": "unavailable"}, + "image-heavy-500mb": {"status": "unavailable"}, + "ten-thousand-page": {"status": "unavailable"}, + "pathological-vector": {"status": "unavailable"}, + "transparency-spots": {"status": "unavailable"}, + }, + "matrix_runs": [{"candidate_sha": "6e65be48e261d14c64653694320c0185b84fc560", "summary": {}}], + "verifiers": [{"command": "test", "result": "pass"}], + } + self.assertIn("must not claim passed", "\n".join(validate_evidence(evidence))) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/qualification/validate_resource_envelope_evidence.py b/scripts/qualification/validate_resource_envelope_evidence.py new file mode 100644 index 000000000..9f1a9478c --- /dev/null +++ b/scripts/qualification/validate_resource_envelope_evidence.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Validate frozen Session 11 resource-envelope qualification evidence.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.resource_envelope.run_matrix import FIXTURE_SPECS + + +DEFAULT_EVIDENCE = ROOT / "docs" / "evidence" / "session-11-resource-envelope" / "evidence.json" +DEFAULT_MANIFEST = ROOT / "docs" / "evidence" / "session-11-resource-envelope" / "fixture-manifest.json" +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +REQUIRED_FIXTURES = tuple( + fixture_id for fixture_id, spec in FIXTURE_SPECS.items() if spec["required"] +) + + +def validate_evidence( + evidence: dict[str, Any], + *, + manifest: dict[str, Any] | None = None, +) -> list[str]: + errors: list[str] = [] + if evidence.get("schema_kind") != "loop-resource-envelope-qualification-evidence": + errors.append("schema_kind must be loop-resource-envelope-qualification-evidence") + if evidence.get("schema_version") != 1: + errors.append("schema_version must be 1") + + candidate_sha = evidence.get("candidate_sha") + if not isinstance(candidate_sha, str) or not SHA_RE.fullmatch(candidate_sha): + errors.append("candidate_sha must be a 40-character lowercase commit SHA") + + disposition = evidence.get("disposition") + if disposition not in {"incomplete", "passed", "rejected"}: + errors.append("disposition must be incomplete, passed, or rejected") + if disposition == "passed": + errors.append("Session 11 evidence must not claim passed until hosted matrix is complete") + + fixture_status = evidence.get("fixture_status") + if not isinstance(fixture_status, dict): + errors.append("fixture_status must be an object") + return errors + + for fixture_id in REQUIRED_FIXTURES: + entry = fixture_status.get(fixture_id) + if not isinstance(entry, dict): + errors.append(f"fixture_status missing required fixture: {fixture_id}") + continue + status = entry.get("status") + if status not in {"measured", "flagged", "failed", "unavailable", "incomplete"}: + errors.append(f"fixture_status.{fixture_id}.status is invalid") + if status in {"measured", "passed"} and entry.get("preflight_high_water_bytes") == -1: + errors.append(f"fixture_status.{fixture_id} cannot pass with unavailable preflight") + + runs = evidence.get("matrix_runs") + if not isinstance(runs, list) or not runs: + errors.append("matrix_runs must be a non-empty array") + else: + for index, run in enumerate(runs): + if not isinstance(run, dict): + errors.append(f"matrix_runs[{index}] must be an object") + continue + run_sha = run.get("candidate_sha") + if isinstance(candidate_sha, str) and run_sha != candidate_sha: + errors.append(f"matrix_runs[{index}].candidate_sha must equal candidate_sha") + + if manifest is not None: + records = manifest.get("fixtures") + if not isinstance(records, list): + errors.append("fixture manifest fixtures must be an array") + + verifiers = evidence.get("verifiers") + if not isinstance(verifiers, list) or not verifiers: + errors.append("verifiers must be a non-empty array") + + return errors + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--evidence", type=Path, default=DEFAULT_EVIDENCE) + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + parser.add_argument("--skip-manifest", action="store_true") + args = parser.parse_args(argv) + + try: + evidence = json.loads(args.evidence.read_text(encoding="utf-8")) + manifest = None if args.skip_manifest or not args.manifest.is_file() else json.loads(args.manifest.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"resource-envelope evidence validation error: {exc}", file=sys.stderr) + return 2 + + errors = validate_evidence(evidence, manifest=manifest) + if errors: + for error in errors: + print(f"resource-envelope-evidence: {error}", file=sys.stderr) + return 1 + + print(json.dumps({"status": "valid", "candidate_sha": evidence.get("candidate_sha")}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/resource_envelope/create_fixture_manifest.py b/scripts/resource_envelope/create_fixture_manifest.py index b3b8de671..a7c05da7f 100644 --- a/scripts/resource_envelope/create_fixture_manifest.py +++ b/scripts/resource_envelope/create_fixture_manifest.py @@ -9,6 +9,10 @@ from pathlib import Path from typing import Sequence +_ROOT = Path(__file__).resolve().parents[2] +if str(_ROOT) not in sys.path: + sys.path.insert(0, str(_ROOT)) + from scripts.resource_envelope.run_matrix import FIXTURE_SPECS, _fixture_args, _sha256